diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94fce7e9..ea7c8821 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,8 @@ jobs: # test jobs rather than gating them: a bracket-spacing nit should not hide # whether the suite passes. lint: - runs-on: ubuntu-latest + # The pinned setup-ruby action provides Ruby 3.4.7 for this image. + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 diff --git a/docs/DEMO_LIBRARY.md b/docs/DEMO_LIBRARY.md index 4d22c62d..54945378 100644 --- a/docs/DEMO_LIBRARY.md +++ b/docs/DEMO_LIBRARY.md @@ -45,6 +45,7 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff. | Content example | What to try | | --- | --- | | writing-legacy-unknown | Pre-identity history repaired as Unknown; recorded agent identities are preserved | +| writing-moved | Original authorship survives reordering, including separate cut and paste autosaves | | diagram-comments | Flowchart node and edge discussions; browse, press C, select an element, expand | | entity-relationships / class-model | ER and class diagrams with whole-diagram discussions | | mobile-checkout / long-title | State and sequence diagrams with whole-diagram discussions | diff --git a/engine/app/services/coplan/plans/writing_provenance.rb b/engine/app/services/coplan/plans/writing_provenance.rb index 9d4701bc..221ae549 100644 --- a/engine/app/services/coplan/plans/writing_provenance.rb +++ b/engine/app/services/coplan/plans/writing_provenance.rb @@ -7,7 +7,7 @@ module Plans # Actor identity is separate from input evidence on subsequent writes. class WritingProvenance FORMAT = 1 - HISTORY_POLICY = 2 + HISTORY_POLICY = 3 # Bearer-only agent identity landed in #178 on August 17. Before then, # host-authenticated API agents were also saved with actor_type "human". RELIABLE_HUMAN_ATTRIBUTION_FROM = Time.utc(2026, 8, 18).freeze @@ -98,11 +98,15 @@ def call [ [ content.length, identity["kind"] == "ai" ? source_key : unknown_key ] ] end runs = self.class.remap(old, content, prior_runs, insertions, fallback_source: @historical_human ? source_key : UNKNOWN) - retained = runs.map(&:last).to_set + # Historical human input is an assumption, not fresh typing evidence. + runs, removed_lines = WritingProvenanceMoves.call(before: old, after: content, prior_runs: prior_runs, runs: runs, + removed_lines: valid ? snapshot.fetch("removed_lines", []) : [], fresh_source: identity["kind"] == "human" && !@historical_human ? source_key : nil) + retained = (runs + removed_lines.flat_map { |_, line_runs| line_runs || [] }).map(&:last).to_set sources.select! { |key, _| retained.include?(key) } summary = summarize(content, runs, sources) result = { "format" => FORMAT, "sha256" => Digest::SHA256.hexdigest(content), "write_source" => identity, "runs" => runs, "sources" => sources, "summary" => summary } + result["removed_lines"] = removed_lines if removed_lines.any? # A new save cannot certify old attribution that it merely retained. result["history_policy"] = HISTORY_POLICY if @previous.nil? || snapshot["history_policy"] == HISTORY_POLICY # Preserve the cutover assumption on replay, including bounded large diff --git a/engine/app/services/coplan/plans/writing_provenance_backfill.rb b/engine/app/services/coplan/plans/writing_provenance_backfill.rb index 718ef22b..b4b36158 100644 --- a/engine/app/services/coplan/plans/writing_provenance_backfill.rb +++ b/engine/app/services/coplan/plans/writing_provenance_backfill.rb @@ -42,7 +42,9 @@ def self.release(plan, token) def self.pending?(plan) return false unless plan.current_revision.positive? state = plan.writing_provenance_backfill - return plan.writing_provenance_summary.nil? unless state + # Plans created after the initial rollout can have a cached summary + # without a checkpoint. Replay them too when a new policy ships. + return true unless state state["policy_version"] != WritingProvenance::HISTORY_POLICY || state["token"].present? || state.fetch("revision", 0) < plan.current_revision || state.fetch("finalized_revision", 0) < plan.current_revision || plan.writing_provenance_summary&.dig("revision") != plan.current_revision end diff --git a/engine/app/services/coplan/plans/writing_provenance_moves.rb b/engine/app/services/coplan/plans/writing_provenance_moves.rb new file mode 100644 index 00000000..7b292cd6 --- /dev/null +++ b/engine/app/services/coplan/plans/writing_provenance_moves.rb @@ -0,0 +1,131 @@ +module CoPlan + module Plans + # Exact lines carry their original character sources when reordered. Keep + # bounded deleted-line evidence so autosaving a cut does not erase it before + # the paste. Conflicting origins for identical lines are never guessed. + class WritingProvenanceMoves + MAX_CHARACTERS = 100_000 + MAX_LINES = 2048 + MAX_RUNS = 4096 + + def self.call(before:, after:, prior_runs:, runs:, removed_lines:, fresh_source: nil) + new.call(before: before, after: after, prior_runs: prior_runs, runs: runs, removed_lines: removed_lines, fresh_source: fresh_source) + end + + def call(before:, after:, prior_runs:, runs:, removed_lines:, fresh_source:) + @lines = {} + current_counts, archived_counts = Hash.new(0), Hash.new(0) + @characters = @run_count = 0 + each_line(before, prior_runs) do |text, line_runs, _| + current_counts[text] += 1 + remember(text, line_runs) + end + removed_lines.each do |text, line_runs, count| + remember(text, line_runs) + archived_counts[text] += count || 1 + end + + # Reserve occurrences the normal diff already retained before assigning + # any lost moves. A new copy earlier in the document cannot spend the + # original occurrence's attribution before we reach the retained one. + matched, after_counts = Hash.new(0), Hash.new(0) + each_line(after, runs) do |text, line_runs, _| + after_counts[text] += 1 + if @lines[text] == line_runs && matched[text] < current_counts[text] + matched[text] += 1 + end + end + remaining = current_counts.dup + matched.each { |text, count| remaining[text] -= count } + archived_remaining = archived_counts.dup + + result = [] + each_line(after, runs) do |text, line_runs, ending_runs| + restored = @lines[text] + extra_typed_copy = fresh_source && after_counts[text] > current_counts[text] + archived_counts[text] && + line_runs.any? { |_, source| source == fresh_source } + if extra_typed_copy + restored = nil + elsif restored && matched[text].positive? && line_runs == restored + matched[text] -= 1 + elsif restored && remaining[text].positive? + remaining[text] -= 1 + restored = retain_fresh_input(line_runs, restored, fresh_source) if fresh_source + elsif restored && archived_remaining[text].positive? + archived_remaining[text] -= 1 + restored = retain_fresh_input(line_runs, restored, fresh_source) if fresh_source + else + restored = nil + end + (restored || line_runs).each { |length, source| WritingProvenance.append(result, length, source) } + ending_runs.each { |length, source| WritingProvenance.append(result, length, source) } + end + deleted = @lines.filter_map do |text, line_runs| + count = current_counts[text] + archived_counts[text] - after_counts[text] + [ text, line_runs, count ] if count.positive? + end + [ result, deleted ] + end + + private + + # Recreating an old line can be a new action: typing an old sentence or + # checking a task again. Archived text cannot erase that observed input. + def retain_fresh_input(runs, archived, fresh_source) + result = [] + index = offset = 0 + runs.each do |length, source| + while length.positive? + old_length, old_source = archived.fetch(index) + count = [ length, old_length - offset ].min + WritingProvenance.append(result, count, source == fresh_source ? source : old_source) + length -= count + offset += count + if offset == old_length + index += 1 + offset = 0 + end + end + end + result + end + + def remember(text, runs) + return unless text.match?(/[^[:space:]]/) + if @lines.key?(text) + @lines[text] = nil unless @lines[text] == runs + elsif @lines.size < MAX_LINES && @characters + text.length <= MAX_CHARACTERS && @run_count + runs.to_a.size <= MAX_RUNS + @lines[text] = runs + @characters += text.length + @run_count += runs.to_a.size + end + end + + # Walk the run stream once; slicing from its start for every line would + # multiply work on documents with many lines and many small edits. + def each_line(content, runs) + index = offset = 0 + take = lambda do |length| + result = [] + while length.positive? + run_length, source = runs.fetch(index) + count = [ length, run_length - offset ].min + WritingProvenance.append(result, count, source) + length -= count + offset += count + if offset == run_length + index += 1 + offset = 0 + end + end + result + end + content.each_line do |line| + text = line.delete_suffix("\n") + line_runs = take.call(text.length) + yield text, line_runs, take.call(line.length - text.length) + end + end + end + end +end diff --git a/engine/app/views/coplan/agent_instructions/guides/editing.text.erb b/engine/app/views/coplan/agent_instructions/guides/editing.text.erb index 3de49701..0807e7f4 100644 --- a/engine/app/views/coplan/agent_instructions/guides/editing.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/editing.text.erb @@ -8,6 +8,9 @@ The summary has `counts`, `shares`, `total`, and `contributors`. Categories are The UI groups known human and AI contributors by name. It shows a separate Unknown row only when unknown text is present. The API categories remain `human`, `ai`, and `unknown`. These describe recorded input, not human review or intellectual ownership. The method is `retained_markdown_v1`. Counts use non-whitespace Markdown characters, including code and diagram source. They are not edit counts or word counts. Unchanged characters retain their origin. A small edit does not take credit for a whole line. Very large replacement diffs may remain Unknown when exact matching exceeds the processing budget. +Reordered exact lines retain their original authors and introduction revisions. This also works when autosave records a cut before its paste, using a bounded record of deleted lines. Identical lines with conflicting origins are not reassigned by this matching. +Recorded new human input takes priority over deleted-line matches. Checking a task again records the person and version of that new action. +Each saved line occurrence can supply attribution once. Additional copies keep their recorded typing or paste source. Identified agent writes are AI written. Observed human input is Human written. External paste and unrecorded new input are Unknown. Deployment automatically rebuilds existing history in resumable background batches. Human-labeled versions before August 18, 2026 are Unknown unless an agent identity was recorded. Later editor changes through the deployment cutoff and uninstrumented saves from old instances during deployment are treated as Human written; this is a historical assumption, not typing/paste evidence. Instrumented changes use recorded input. Unchanged agent text keeps its AI origin. AI contributions can report `agent_name`, `harness`, `harness_name`, `harness_version`, and `model`. Missing model information stays an AI contribution. Identity is reported by the tool and captured with its write. diff --git a/engine/db/demo/content/writing-moved.md b/engine/db/demo/content/writing-moved.md new file mode 100644 index 00000000..dda9adc9 --- /dev/null +++ b/engine/db/demo/content/writing-moved.md @@ -0,0 +1,11 @@ +## Current Architecture + +Today, devices connect directly to the cloud for delivery and networking. + +The original author wrote this section before moving it below the proposal. + +## Proposal + +Let nearby devices exchange data securely over the local network. + +Inspect writing to see Hampton as the author of both sections. The history records the cut and paste as separate saves, while the moved text keeps its original version. diff --git a/engine/lib/coplan/development_seed.rb b/engine/lib/coplan/development_seed.rb index 3342b040..24829e59 100644 --- a/engine/lib/coplan/development_seed.rb +++ b/engine/lib/coplan/development_seed.rb @@ -59,6 +59,9 @@ module DevelopmentSeed { key: "writing-human", author: "hampton", type: "General", title: "Writing sources: human written", tags: %w[demo provenance], visibility: "published", folder: "Demo content/Writing sources", group: "Writing sources", writing_source: "human", showcase: "Person icon; recorded human input throughout" }, + { key: "writing-moved", author: "hampton", type: "General", title: "Writing sources: moved paragraphs keep their author", + tags: %w[demo provenance], visibility: "published", folder: "Demo content/Writing sources", group: "Writing sources", writing_source: "human", + showcase: "Hampton's architecture section keeps its original author after separate cut and paste autosaves; inspect writing and history" }, { key: "writing-ai", author: "hampton", type: "General", title: "Writing sources: AI written", tags: %w[demo provenance], visibility: "published", folder: "Demo content/Writing sources", group: "Writing sources", writing_source: "ai", showcase: "Robot icon; Claude Code attribution; inspect the source of each word" }, @@ -283,6 +286,7 @@ def call(base_url: ENV.fetch("COPLAN_DEMO_BASE_URL", "http://localhost:3000")) seed_small_edits(users, plans) seed_writing_sources(users, plans) seed_writing_word_edit(users, plans) + seed_moved_writing(users, plans) seed_reviews(users, plans) seed_review_requests(users, plans) seed_agent_access(users) @@ -809,6 +813,20 @@ def seed_writing_word_edit(users, plans) input_sources: { "runs" => [ [ changed.encode(Encoding::UTF_16LE).bytesize / 2, "human" ] ] }) end + def seed_moved_writing(users, plans) + plan = plans.fetch("writing-moved") + content = document_content(DOCUMENTS.find { |item| item[:key] == "writing-moved" }) + return unless plan.current_revision == 1 && plan.current_content == content + title, sections = content.split("## Current Architecture", 2) + moved, kept = ("## Current Architecture" + sections).split("## Proposal", 2) + kept = "## Proposal" + kept + [ [ title + kept, "Cut architecture before reordering" ], [ title + kept + "\n" + moved, "Paste architecture below the proposal" ] ].each do |text, summary| + Plans::ReplaceContent.call(plan: plan, new_content: text, base_revision: plan.current_revision, + actor_type: "human", actor_id: users.fetch("hampton").id, change_summary: summary, + input_sources: { "runs" => [ [ text.encode(Encoding::UTF_16LE).bytesize / 2, "unknown" ] ] }) + end + end + # Demo fixture origin is explicit simulation data. Reconstruct only seed-owned # initial versions; never replace the input evidence from a person's later edit. def seed_writing_history(users, plans, catalog) diff --git a/spec/jobs/writing_provenance_backfill_job_spec.rb b/spec/jobs/writing_provenance_backfill_job_spec.rb index c39551d6..592db706 100644 --- a/spec/jobs/writing_provenance_backfill_job_spec.rb +++ b/spec/jobs/writing_provenance_backfill_job_spec.rb @@ -17,7 +17,7 @@ it "does not rebuild completed documents" do plan = CoPlan::Plans::Create.call(title: "Ready", content: "Editor", user: create(:coplan_user), actor_type: "human") - plan.update_columns(writing_provenance_backfill: { "legacy_revision" => 1, "revision" => 1, "finalized_revision" => 1, "policy_version" => 2 }) + plan.update_columns(writing_provenance_backfill: { "legacy_revision" => 1, "revision" => 1, "finalized_revision" => 1, "policy_version" => CoPlan::Plans::WritingProvenance::HISTORY_POLICY }) expect(CoPlan::WritingProvenanceJob).not_to receive(:request) expect { described_class.perform_now }.not_to have_enqueued_job(described_class) end @@ -29,6 +29,14 @@ described_class.perform_now end + it "revisits cached summaries without a backfill checkpoint" do + plan = CoPlan::Plans::Create.call(title: "Created after rollout", content: "Known writing", user: create(:coplan_user), actor_type: "human") + expect(plan.writing_provenance_summary).to be_present + expect(plan.writing_provenance_backfill).to be_nil + expect(CoPlan::WritingProvenanceJob).to receive(:request).with(have_attributes(id: plan.id)) + described_class.perform_now + end + it "discovers plans created by old instances after the migration scan" do plan = CoPlan::Plans::Create.call(title: "During cutover", content: "Editor", user: create(:coplan_user), actor_type: "human") plan.plan_versions.update_all(writing_provenance: nil) diff --git a/spec/lib/development_seed_spec.rb b/spec/lib/development_seed_spec.rb index 2dd48d71..a3fc7acf 100644 --- a/spec/lib/development_seed_spec.rb +++ b/spec/lib/development_seed_spec.rb @@ -8,7 +8,7 @@ expect(seeded_plan("writing-human").writing_provenance_summary.dig("shares", "human")).to eq(1.0) legacy = seeded_plan("writing-legacy") expect(legacy.writing_provenance_summary.dig("shares", "human")).to eq(1.0) - expect(legacy.writing_provenance_backfill).to eq("legacy_revision" => 1, "revision" => 1, "finalized_revision" => 1, "policy_version" => 2) + expect(legacy.writing_provenance_backfill).to eq("legacy_revision" => 1, "revision" => 1, "finalized_revision" => 1, "policy_version" => CoPlan::Plans::WritingProvenance::HISTORY_POLICY) early = seeded_plan("writing-legacy-unknown") expect(early.writing_provenance_summary.dig("shares", "unknown")).to eq(1.0) expect(early.current_plan_version.created_at).to eq(Time.utc(2026, 7, 9)) @@ -42,6 +42,25 @@ def seeded_plan(key) CoPlan::Plan.all.detect { |plan| plan.metadata.to_h["development_seed_key"] == key } end + it "keeps moved writing attributed to its original author and preserves it on re-seed" do + described_class.call + moved = seeded_plan("writing-moved") + expect(moved.current_revision).to eq(3) + expect(moved.current_content.index("## Proposal")).to be < moved.current_content.index("## Current Architecture") + expect(moved.writing_provenance_summary.dig("shares", "human")).to eq(1.0) + expect(moved.writing_provenance_summary.fetch("contributors")).to contain_exactly(include("user_name" => "Hampton", "kind" => "human")) + expect(moved.current_plan_version.writing_provenance.fetch("sources").values).to include(include("origin_revision" => 1, "user_name" => "Hampton")) + summary = moved.writing_provenance_summary.deep_dup + described_class.call + expect(moved.reload.current_revision).to eq(3) + expect(moved.writing_provenance_summary).to eq(summary) + CoPlan::Plans::ReplaceContent.call(plan: moved, new_content: "My own revised arrangement", base_revision: 3, + actor_type: "human", actor_id: moved.created_by_user_id) + described_class.call + expect(moved.reload.current_content).to eq("My own revised arrangement") + expect(seeded_plan("demo-library").current_content).to include(moved.url_path) + end + it "refuses non-local environments before creating data" do allow(Rails).to receive(:env).and_return(ActiveSupport::EnvironmentInquirer.new("production")) diff --git a/spec/requests/toggle_checkbox_spec.rb b/spec/requests/toggle_checkbox_spec.rb index df474f77..f2a8d637 100644 --- a/spec/requests/toggle_checkbox_spec.rb +++ b/spec/requests/toggle_checkbox_spec.rb @@ -56,6 +56,8 @@ it "attributes repeated checkbox selections to the person while retaining the agent's task text" do agent_plan = CoPlan::Plans::Create.call(title: "Agent tasks", content: "- [ ] Ship ๐Ÿ˜€\n", user: user, actor_type: "local_agent") 5.times do |index| + actor = index < 2 ? user : other_user + sign_in_as(actor) checked = index.even? patch toggle_checkbox_plan_path(agent_plan), params: { old_text: checked ? "- [ ] Ship ๐Ÿ˜€" : "- [x] Ship ๐Ÿ˜€", @@ -66,11 +68,9 @@ snapshot = agent_plan.reload.current_plan_version.writing_provenance expect(snapshot.dig("summary", "counts", "unknown")).to eq(0) expect(snapshot.dig("summary", "counts", "ai")).to eq(8) - if checked - source = snapshot.fetch("sources").values.find { |entry| entry["kind"] == "human" } - expect(source).to include("user_id" => user.id, "origin_revision" => agent_plan.current_revision) - expect(snapshot.dig("summary", "counts", "human")).to eq(1) - end + marker_key = CoPlan::Plans::WritingProvenance.slice(snapshot.fetch("runs"), 3, 4).first.last + expect(snapshot.fetch("sources").fetch(marker_key)).to include("kind" => "human", "user_id" => actor.id, "origin_revision" => agent_plan.current_revision) + expect(snapshot.dig("summary", "counts", "human")).to eq(checked ? 1 : 0) end end diff --git a/spec/services/plans/writing_provenance_backfill_spec.rb b/spec/services/plans/writing_provenance_backfill_spec.rb index bd038fa3..ba0c7084 100644 --- a/spec/services/plans/writing_provenance_backfill_spec.rb +++ b/spec/services/plans/writing_provenance_backfill_spec.rb @@ -13,6 +13,36 @@ def run_batch(plan, token = described_class.claim(plan)) described_class.call(plan: plan, token: token) end + it "repairs moved writing from an old completed policy across separate cut and paste saves" do + original = "# Current Architecture\n\nDevices connect to the cloud.\n\n" + kept = "# Proposal\n\nConnect devices directly.\n" + moved = CoPlan::Plans::Create.call(title: "Moved writing", content: original + kept, user: user, actor_type: "human", + input_sources: { "runs" => [ [ (original + kept).length, "human" ] ] }) + allow(CoPlan::Plans::WritingProvenanceMoves).to receive(:call) { |**args| [ args.fetch(:runs), [] ] } + CoPlan::Plans::ReplaceContent.call(plan: moved, new_content: kept, base_revision: 1, actor_type: "human", actor_id: user.id) + pasted = kept + "\n" + original + CoPlan::Plans::ReplaceContent.call(plan: moved, new_content: pasted, base_revision: 2, actor_type: "human", actor_id: user.id, + input_sources: { "runs" => [ [ pasted.length, "unknown" ] ] }) + expect(moved.writing_provenance_summary.dig("counts", "unknown")).to be_positive + moved.plan_versions.each do |version| + version.update_columns(writing_provenance: version.writing_provenance.merge("history_policy" => 2)) + end + moved.update_columns(writing_provenance_backfill: { "legacy_revision" => 0, "revision" => 3, "finalized_revision" => 3, "policy_version" => 2 }) + allow(CoPlan::Plans::WritingProvenanceMoves).to receive(:call).and_call_original + expect(described_class.pending?(moved)).to be(true) + + run_batch(moved) + + summary = moved.reload.writing_provenance_summary + expect(summary.dig("shares", "human")).to eq(1.0) + expect(summary.dig("counts", "unknown")).to eq(0) + expect(moved.current_content).to eq(pasted) + expect(moved.plan_versions.count).to eq(3) + expect(moved.current_plan_version.writing_provenance.fetch("sources").values).to include(include("kind" => "human", "origin_revision" => 1, "user_id" => user.id)) + run_batch(moved, described_class.claim(moved, restart: true)) + expect(moved.reload.writing_provenance_summary).to eq(summary) + end + it "repairs completed pre-identity history without changing content or actor records" do old = plan.current_plan_version old.update_columns(created_at: Time.utc(2026, 7, 9), writing_provenance: old.writing_provenance.except("history_policy")) @@ -103,7 +133,7 @@ def run_batch(plan, token = described_class.claim(plan)) input_sources: { "runs" => [ [ 10, "unknown" ], [ 6, "unknown" ] ] }) run_batch(plan) expect(plan.reload.writing_provenance_summary.fetch("counts")).to eq("human" => 9, "ai" => 0, "unknown" => 5) - expect(plan.writing_provenance_backfill).to eq("legacy_revision" => 1, "revision" => 2, "finalized_revision" => 2, "policy_version" => 2) + expect(plan.writing_provenance_backfill).to eq("legacy_revision" => 1, "revision" => 2, "finalized_revision" => 2, "policy_version" => CoPlan::Plans::WritingProvenance::HISTORY_POLICY) end it "keeps retained agent writing when a historical human adds text" do @@ -114,6 +144,20 @@ def run_batch(plan, token = described_class.claim(plan)) expect(agent.reload.writing_provenance_summary.fetch("counts")).to eq("human" => 5, "ai" => 5, "unknown" => 0) end + it "keeps moved agent writing during historical human-input reconstruction" do + first = "Agent-authored architecture.\n\n" + last = "Agent-authored proposal.\n" + agent = CoPlan::Plans::Create.call(title: "Reordered history", content: first + last, user: user, actor_type: "local_agent") + reordered = last + "\n" + first + CoPlan::Plans::ReplaceContent.call(plan: agent, new_content: reordered, base_revision: 1, actor_type: "human", actor_id: user.id) + rollout(agent) + run_batch(agent) + expect(agent.reload.writing_provenance_summary.dig("shares", "ai")).to eq(1.0) + summary = agent.writing_provenance_summary.deep_dup + run_batch(agent, described_class.claim(agent, restart: true)) + expect(agent.reload.writing_provenance_summary).to eq(summary) + end + it "includes old writers after the cutoff without treating interleaved instrumented paste as human" do CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: "Old editor oldworker", base_revision: 1, actor_type: "human", actor_id: user.id) pasted = "Old editor oldworker paste" @@ -177,7 +221,7 @@ def run_batch(plan, token = described_class.claim(plan)) expect(run_batch(plan, token)).to be(false) CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: "Old editor typed", base_revision: 1, actor_type: "human", actor_id: user.id, input_sources: { "runs" => [ [ 16, "human" ] ] }) - expect(plan.reload.writing_provenance_backfill).to eq("legacy_revision" => 1, "revision" => 2, "finalized_revision" => 2, "policy_version" => 2) + expect(plan.reload.writing_provenance_backfill).to eq("legacy_revision" => 1, "revision" => 2, "finalized_revision" => 2, "policy_version" => CoPlan::Plans::WritingProvenance::HISTORY_POLICY) expect(described_class.pending?(plan)).to be(false) end diff --git a/spec/services/plans/writing_provenance_spec.rb b/spec/services/plans/writing_provenance_spec.rb index f789fa30..839526ee 100644 --- a/spec/services/plans/writing_provenance_spec.rb +++ b/spec/services/plans/writing_provenance_spec.rb @@ -17,6 +17,15 @@ def edit(plan, content, kind: "local_agent", input_sources: nil) actor_type: kind, actor_id: user.id, input_sources: input_sources) end + def character_sources(content, snapshot) + offset = 0 + snapshot.fetch("runs").flat_map do |length, key| + text = content[offset, length] + offset += length + text.each_char.filter_map { |char| [ char, key ] unless char.match?(/[[:space:]]/) } + end + end + it "snapshots harness and model identity without exposing a credential" do token, = CoPlan::ApiToken.create_with_raw_token(user: user, name: "Codex", agent_name: "Codex", metadata: { "harness" => "codex", "model" => "reported-model", "harness_version" => "1.2" }) @@ -78,6 +87,194 @@ def edit(plan, content, kind: "local_agent", input_sources: nil) expect(plan.writing_provenance_summary.fetch("counts").fetch("human")).to eq(text.delete(" ").length) end + it "keeps the exact authors and origin revisions when mixed writing is reordered" do + human = "# Current Architecture\n\nDevices connect directly to the cloud. ๐Ÿ˜€\n\n" + agent = "# Proposal\n\nUse certificates to connect devices.\n" + plan = make_plan(human, input_sources: sources(human)) + edit(plan, human + agent) + original = plan.current_plan_version.writing_provenance.deep_dup + + edit(plan, agent + "\n" + human, kind: "human", input_sources: sources(agent + "\n" + human, "unknown")) + + snapshot = plan.current_plan_version.writing_provenance + expect(snapshot.fetch("summary").fetch("counts")).to eq(original.fetch("summary").fetch("counts")) + expected = character_sources(human + agent, original) + boundary = human.gsub(/[[:space:]]/, "").length + expect(character_sources(plan.current_content, snapshot)).to eq(expected[boundary..] + expected[0...boundary]) + expect(plan.plan_versions.find_by!(revision: 2).writing_provenance).to eq(original) + end + + it "restores authorship after autosave records the cut before the paste" do + moved = "# Current Architecture\n\nOriginal architecture written by a person.\n\n" + kept = "# Proposal\n\nKeep this proposal in place.\n" + plan = make_plan(moved + kept, input_sources: sources(moved + kept)) + original = plan.current_plan_version.writing_provenance.deep_dup + edit(plan, kept, kind: "human", input_sources: sources(kept, "unknown")) + edit(plan, kept + "\nA newly typed note.\n", kind: "human", input_sources: sources(kept + "\nA newly typed note.\n")) + pasted = plan.current_content + "\n" + moved + edit(plan, pasted, kind: "human", input_sources: sources(pasted, "unknown")) + + snapshot = plan.current_plan_version.writing_provenance + offset = pasted.index(moved) + recovered = { "runs" => described_class.slice(snapshot.fetch("runs"), offset, offset + moved.length) } + expect(character_sources(moved, recovered)).to eq(character_sources(moved, { "runs" => described_class.slice(original.fetch("runs"), 0, moved.length) })) + expect(snapshot.dig("summary", "counts", "unknown")).to eq(0) + expect(snapshot.dig("summary", "contributors")).to all(include("kind" => "human", "user_id" => user.id)) + end + + it "does not turn moved agent writing into human writing" do + moved = "Agent-written architecture.\n" + plan = make_plan(moved + "Other agent writing.\n", kind: "local_agent") + original = plan.current_plan_version.writing_provenance.deep_dup + edit(plan, "Other agent writing.\n", kind: "human", input_sources: sources("Other agent writing.\n")) + pasted = "Other agent writing.\n" + moved + edit(plan, pasted, kind: "human", input_sources: sources(pasted, "unknown")) + expect(plan.writing_provenance_summary.fetch("counts")).to eq(original.fetch("summary").fetch("counts")) + expect(character_sources(pasted, plan.current_plan_version.writing_provenance)).to eq(character_sources(moved + "Other agent writing.\n", original).rotate(moved.gsub(/[[:space:]]/, "").length)) + end + + it "credits observed typing that recreates a deleted line to the new writer" do + sentence = "Original sentence from the agent.\n" + plan = make_plan(sentence + "Keep this paragraph.\n", kind: "local_agent") + edit(plan, "Keep this paragraph.\n") + writer = create(:coplan_user) + content = plan.current_content + sentence + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: content, base_revision: 2, + actor_type: "human", actor_id: writer.id, input_sources: sources(content)) + + snapshot = plan.current_plan_version.writing_provenance + runs = described_class.slice(snapshot.fetch("runs"), content.index(sentence), content.length) + expect(runs.map { |_, key| snapshot.fetch("sources").fetch(key) }).to all(include("kind" => "human", "user_id" => writer.id, "origin_revision" => 3)) + expect(snapshot.dig("summary", "counts", "ai")).to eq("Keepthisparagraph.".length) + expect(snapshot.dig("summary", "counts", "unknown")).to eq(0) + end + + %w[human unknown].each do |kind| + %w[before after].each do |position| + it "keeps #{kind} input on an extra copy #{position} an existing identical line" do + line = "Repeated architecture ๐Ÿ˜€.\n" + original = "Opening context.\n#{line}Closing context.\n" + plan = make_plan(original, kind: "local_agent") + content = position == "before" ? line + original : original + line + edit(plan, content, kind: "human", input_sources: sources(content, kind)) + + snapshot = plan.current_plan_version.writing_provenance + fresh_at = position == "before" ? 0 : original.length + retained_at = original.index(line) + (position == "before" ? line.length : 0) + fresh = described_class.slice(snapshot.fetch("runs"), fresh_at, fresh_at + line.length - 1) + retained = described_class.slice(snapshot.fetch("runs"), retained_at, retained_at + line.length - 1) + expect(fresh.map { |_, key| snapshot.fetch("sources").fetch(key) }).to all(include("kind" => kind, "user_id" => user.id, "origin_revision" => 2)) + expect(retained.map { |_, key| snapshot.fetch("sources").fetch(key) }).to all(include("kind" => "ai", "origin_revision" => 1)) + expect(snapshot.dig("summary", "counts", "ai")).to eq(original.gsub(/[[:space:]]/, "").length) + expect(snapshot.dig("summary", "counts", kind)).to eq(line.gsub(/[[:space:]]/, "").length) + end + end + end + + it "restores only the saved number of archived copies" do + line = "Agent-authored paragraph.\n" + kept = "Remaining context.\n" + plan = make_plan(line + kept, kind: "local_agent") + edit(plan, kept, kind: "human", input_sources: sources(kept, "unknown")) + content = kept + line * 2 + edit(plan, content, kind: "human", input_sources: sources(content, "unknown")) + expect(plan.writing_provenance_summary.fetch("counts")).to eq("human" => 0, + "ai" => (line + kept).gsub(/[[:space:]]/, "").length, "unknown" => line.gsub(/[[:space:]]/, "").length) + end + + it "reserves a moved occurrence when a newly typed copy appears before it" do + line = "Agent-authored paragraph.\n" + kept = (1..10).map { |i| "Unchanged context #{i}.\n" }.join + plan = make_plan(line + kept, kind: "local_agent") + content = kept + line * 2 + edit(plan, content, kind: "human", input_sources: { "runs" => [ [ kept.length, "unknown" ], [ line.length, "human" ], [ line.length, "unknown" ] ] }) + snapshot = plan.current_plan_version.writing_provenance + typed = described_class.slice(snapshot.fetch("runs"), kept.length, kept.length + line.length - 1) + moved = described_class.slice(snapshot.fetch("runs"), kept.length + line.length, content.length - 1) + expect(typed.map { |_, key| snapshot.fetch("sources").fetch(key) }).to all(include("kind" => "human", "origin_revision" => 2)) + expect(moved.map { |_, key| snapshot.fetch("sources").fetch(key) }).to all(include("kind" => "ai", "origin_revision" => 1)) + end + + it "remembers a cut occurrence even when an identical occurrence remains" do + line = "Agent-authored paragraph.\n" + kept = "Remaining context.\n" + original = line * 2 + kept + plan = make_plan(original, kind: "local_agent") + cut = line + kept + edit(plan, cut, kind: "human", input_sources: sources(cut, "unknown")) + pasted = cut + line + edit(plan, pasted, kind: "human", input_sources: sources(pasted, "unknown")) + expect(plan.writing_provenance_summary.dig("shares", "ai")).to eq(1.0) + snapshot = plan.current_plan_version.writing_provenance + expect(character_sources(pasted, snapshot).map { |_, key| snapshot.fetch("sources").fetch(key) }).to all(include("kind" => "ai", "origin_revision" => 1)) + end + + it "reads deleted-line evidence saved before occurrence counts were recorded" do + line = "Agent-authored paragraph.\n" + kept = "Remaining context.\n" + plan = make_plan(line + kept, kind: "local_agent") + edit(plan, kept) + version = plan.current_plan_version + snapshot = version.writing_provenance.deep_dup + snapshot["removed_lines"] = snapshot.fetch("removed_lines").map { |text, runs, _| [ text, runs ] } + version.update_columns(writing_provenance: snapshot) + pasted = kept + line + edit(plan, pasted, kind: "human", input_sources: sources(pasted, "unknown")) + expect(plan.writing_provenance_summary.dig("shares", "ai")).to eq(1.0) + end + + { "human" => "human", "local_agent" => "ai" }.each do |actor_type, kind| + [ "๐Ÿ˜€๐Ÿ‘‹\n", "---\n", "{}();\n" ].each do |line| + it "keeps #{kind} attribution when #{line.inspect} is cut and pasted across saves" do + kept = "Remaining context.\n" + original = line + kept + plan = make_plan(original, kind: actor_type, input_sources: sources(original)) + edit(plan, kept, kind: "human", input_sources: sources(kept, "unknown")) + pasted = kept + line + edit(plan, pasted, kind: "human", input_sources: sources(pasted, "unknown")) + snapshot = plan.current_plan_version.writing_provenance + expect(snapshot.dig("summary", "shares", kind)).to eq(1.0) + moved = described_class.slice(snapshot.fetch("runs"), kept.length, pasted.length - 1) + expect(moved.map { |_, key| snapshot.fetch("sources").fetch(key) }).to all(include("kind" => kind, "origin_revision" => 1)) + end + end + end + + it "keeps external paste unknown while recovering a moved line" do + plan = make_plan("Human paragraph.\nOther paragraph.\n", input_sources: sources("Human paragraph.\nOther paragraph.\n")) + pasted = "Other paragraph.\nHuman paragraph.\nExternal evidence.\n" + edit(plan, pasted, kind: "human", input_sources: sources(pasted, "unknown")) + expect(plan.writing_provenance_summary.fetch("counts")).to eq("human" => 30, "ai" => 0, "unknown" => 17) + end + + it "does not choose an author for moved duplicate lines with conflicting origins" do + text = "Repeated paragraph.\n" + plan = make_plan(text, input_sources: sources(text)) + # Simulate two identical passages with different recorded authors. + version = plan.current_plan_version + snapshot = version.writing_provenance.deep_dup + key = snapshot.fetch("runs").first.last + snapshot["sources"]["other"] = snapshot.fetch("sources").fetch(key).merge("kind" => "ai") + snapshot["runs"] += [ [ text.length, "other" ] ] + snapshot["sha256"] = Digest::SHA256.hexdigest(text * 2) + version.update_columns(content_markdown: text * 2, content_sha256: snapshot["sha256"], writing_provenance: snapshot) + edit(plan, "Kept text.\n", kind: "human", input_sources: sources("Kept text.\n")) + pasted = "Kept text.\n" + text + edit(plan, pasted, kind: "human", input_sources: sources(pasted, "unknown")) + snapshot = plan.current_plan_version.writing_provenance + expect(described_class.slice(snapshot.fetch("runs"), 11, pasted.length).map { |_, source| snapshot.fetch("sources").fetch(source).fetch("kind") }.uniq).to eq([ "unknown" ]) + end + + it "bounds deleted-line evidence and excludes it from current contribution counts" do + stub_const("CoPlan::Plans::WritingProvenanceMoves::MAX_CHARACTERS", 20) + original = "Small passage.\n" + "A much larger deleted passage.\n" + plan = make_plan(original, input_sources: sources(original)) + edit(plan, "Replacement.\n") + snapshot = plan.current_plan_version.writing_provenance + expect(snapshot.fetch("removed_lines").map(&:first)).to eq([ "Small passage." ]) + expect(snapshot.fetch("summary").fetch("total")).to eq("Replacement.".length) + end + it "fails toward unknown for malformed evidence and split surrogate pairs" do [ { "runs" => [ [ 1, "human" ] ] }, { "runs" => [ [ 1, "human" ], [ 1, "unknown" ] ] }, { "runs" => [ [ 2, "ai" ] ] }, { "runs" => "human" } ].each do |input| diff --git a/spec/system/writing_provenance_spec.rb b/spec/system/writing_provenance_spec.rb index f30e267b..93ce1576 100644 --- a/spec/system/writing_provenance_spec.rb +++ b/spec/system/writing_provenance_spec.rb @@ -43,6 +43,66 @@ def writing_kind(text) snapshot.fetch("sources").fetch(run.last).fetch("kind") end + context "when reordering a person's writing" do + let(:content) { "# Current Architecture\n\nDevices connect directly to the cloud.\n\n# Proposal\n\nConnect devices securely over the local network.\n" } + let(:plan) do + CoPlan::Plans::Create.call(title: "Moved writing", content: content, user: user, actor_type: "human", + input_sources: { "runs" => [ [ content.length, "human" ] ] }) + end + + it "retains the author and introduction version after separate rich-editor cut and paste autosaves" do + open_editor + paragraph = "Devices connect directly to the cloud." + page.execute_script(<<~JS, content.index(paragraph), paragraph.length) + const controller = Stimulus.getControllerForElementAndIdentifier(document.querySelector('form.document-editor'), 'coplan--editor') + controller.richEditor.selectSource(arguments[0], arguments[0] + arguments[1]) + const clipboard = new DataTransfer() + controller.richEditor.view.dom.dispatchEvent(new ClipboardEvent('cut', {clipboardData: clipboard, bubbles: true, cancelable: true})) + window.movedWriting = clipboard.getData('text/plain') + JS + expect(page).to have_css('[data-coplan--editor-target="statusText"]', text: "All changes saved ยท v2", visible: :all, wait: 15) + expect(plan.reload.current_content).not_to include("Devices connect directly") + surface = find('[aria-label="Document body"]') + page.execute_script(<<~JS, plan.current_content.length) + const controller = Stimulus.getControllerForElementAndIdentifier(document.querySelector('form.document-editor'), 'coplan--editor') + controller.richEditor.selectSource(arguments[0], arguments[0]) + JS + surface.send_keys(:enter) + page.execute_script(<<~JS, surface) + const clipboard = new DataTransfer() + clipboard.setData('text/plain', window.movedWriting) + arguments[0].dispatchEvent(new ClipboardEvent('paste', {clipboardData: clipboard, bubbles: true, cancelable: true})) + JS + expect(page).to have_css('[data-coplan--editor-target="statusText"]', text: "All changes saved ยท v3", visible: :all, wait: 15) + expect(plan.reload.current_content.index("Connect devices securely")).to be < plan.current_content.index(paragraph) + expect(writing_kind("Devices connect directly")).to eq("human") + expect(plan.writing_provenance_summary.dig("shares", "human")).to eq(1.0) + within("#plan-toolbar") { click_link "Done" } + expect(page).to have_no_css('.inline-editor form.document-editor', wait: 20) + find(".writing-provenance__trigger").click + expect(page).to have_css(".writing-provenance__counts", text: user.name) + expect(page).to have_no_css(".writing-provenance__counts dt", text: "Unknown") + click_button "Inspect writing" + expect(page).to have_button("Done inspecting") + point = page.evaluate_script(<<~JS) + (() => { + const root = [...document.querySelectorAll('#plan-content-body p')].find(node => node.textContent.includes('Devices connect directly')) + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + let node + while (node = walker.nextNode()) { + const at = node.textContent.indexOf('Devices') + if (at < 0) continue + const range = document.createRange(); range.setStart(node, at); range.setEnd(node, at + 7) + const rect = range.getBoundingClientRect() + return {x: rect.x + rect.width / 2, y: rect.y + rect.height / 2} + } + })() + JS + page.driver.browser.execute_cdp("Input.dispatchMouseEvent", type: "mouseMoved", x: point.fetch("x"), y: point.fetch("y")) + expect(page).to have_css('.writing-inspection__tooltip:popover-open', text: "Added in v1") + end + end + it "keeps mixed typed and pasted citation text separate from retained agent writing" do content = "Read source[^ref].\n\n[^ref]: Original evidence.\n" CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: content, base_revision: 1, actor_type: "local_agent", actor_id: user.id) @@ -262,7 +322,7 @@ def writing_kind(text) JS expect(page).to have_css('#writing-provenance[data-coplan--writing-provenance-revision-value="2"]', wait: 20) expect(plan.reload.writing_provenance_summary.dig("counts", "unknown")).to eq(13) - expect(plan.writing_provenance_backfill).to eq("legacy_revision" => 1, "revision" => 2, "finalized_revision" => 2, "policy_version" => 2) + expect(plan.writing_provenance_backfill).to eq("legacy_revision" => 1, "revision" => 2, "finalized_revision" => 2, "policy_version" => CoPlan::Plans::WritingProvenance::HISTORY_POLICY) end it "attributes repeated prose to its writer rather than an earlier diagram label" do