Files
vscode-cowriting-plugin/src/attributionController.ts
T
BenStullsBets 447a1170ce feat: baseline router — git HEAD + snapshot per INV-7/D13/D14; retire machine-landing advance (spec §6.4)
Task 2 of the native-surfaces migration plan. GitBaselineAdapter resolves a
document's HEAD blob via the built-in vscode.git extension (hardened with a
.git/logs/HEAD watch that nudges repo.status(), since the git extension's own
watcher doesn't reliably notice out-of-band commits on non-workspace-folder
repos in the E2E host). DiffViewController is reworked into a baseline router:
head mode (git-tracked, never persisted, re-read on commit) vs snapshot mode
(captured on CoeditingRegistry entry, re-pinned by "Mark Changes as Reviewed"
— renamed from cowriting.pinDiffBaseline, D14). The shipped machine-landing
baseline advance (#48/INV-18) is retired: a landed Claude edit now stays a
visible change-since-baseline until commit or review (INV-7/D21). Legacy
on-disk reasons ("opened"/"machine-landing") migrate to "entered" via
BaselineStore.normalizeReason on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:09:55 -07:00

422 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AttributionController — the thin editor-facing layer for F3 (spec §6.2).
* Wires the pure AttributionTracker + PendingEditRegistry + Store/Anchorer to
* the editor: human typing → human spans, seam edits → agent spans (INV-9),
* save-time persistence, load/external-change resolve-or-orphan
* (INV-1/INV-6), and the orphan status-bar count. The editor carries no
* in-editor attribution decorations — the rendered preview is the single
* review surface (F10/INV-32); spansFor() feeds it the live attribution.
* Sidecar self-write suppression and the shared
* FileSystemWatcher live in CoauthorStore / extension.ts (the sidecar is
* co-owned with ThreadController).
*/
import * as fs from "node:fs";
import * as vscode from "vscode";
import { SidecarRouter, docIdentity } from "./sidecarRouter";
import { newId, type AttributionRecord, type Provenance } from "./model";
import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
import { gitUserEmail } from "./identity";
import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
import type { AuthorSpan } from "./trackChangesModel";
/** Test-facing snapshot of live attribution state for a document. */
export interface RenderedSpan {
id: string;
authorKind: "human" | "agent";
authorId: string;
turnId?: string;
range: { start: number; end: number };
}
interface DocAttribution {
docPath: string;
spans: LiveSpan[];
/** persisted records whose fingerprints failed to resolve (PUC-4). */
orphans: AttributionRecord[];
/** record metadata per live span id (updatedAt bookkeeping). */
records: Map<string, AttributionRecord>;
/**
* true once this doc has had any span/record this session — lets save persist
* deliberate deletion to empty (so stale records don't come back as phantom
* orphans on reload).
*/
hadAttributions: boolean;
}
export class AttributionController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocAttribution>();
private readonly pending = new PendingEditRegistry();
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 90);
private readonly output = vscode.window.createOutputChannel("Cowriting Attribution");
/**
* F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real
* (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit
* ingress, so this is the sole signal. Native-surfaces migration (INV-7/
* INV-18 retirement, spec §6.4): DiffViewController no longer subscribes
* here to advance the baseline — a landed edit stays a visible
* change-since-baseline until commit (head mode) or "Mark Changes as
* Reviewed" (snapshot mode). Other consumers may still subscribe.
*/
private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>();
readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event;
constructor(
private readonly store: SidecarRouter,
private readonly rootDir: string | undefined,
private readonly guard: VersionGuard,
) {
this.disposables.push(this.statusItem, this.output, this.applyEmitter);
this.disposables.push(
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()),
);
}
private isTracked(document: vscode.TextDocument): boolean {
return isAuthorable(document.uri.scheme);
}
/** The single document key (F8): repo-relative path in-workspace, URI string otherwise. */
private keyOf(document: vscode.TextDocument): string {
return this.store.keyOf(docIdentity(document));
}
private currentAuthor(): Provenance {
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined;
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
}
private state(docPath: string): DocAttribution {
let s = this.docs.get(docPath);
if (!s) {
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false };
this.docs.set(docPath, s);
}
return s;
}
// ---- PUC-4: load / resolve-or-orphan ---------------------------------------------
/** Load the sidecar and re-resolve every attribution (live span | orphan). */
loadAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
const docPath = this.keyOf(document);
const s = this.state(docPath);
const artifact = this.store.load(docPath);
s.spans = [];
s.orphans = [];
s.records.clear();
if (artifact) {
const text = document.getText();
for (const rec of artifact.attributions) {
const fp = artifact.anchors[rec.anchorId]?.fingerprint;
const resolved = fp ? resolve(text, fp) : "orphaned";
if (resolved === "orphaned") {
s.orphans.push(rec);
} else {
s.spans.push({
id: rec.id, start: resolved.start, end: resolved.end,
author: rec.author, turnId: rec.turnId, createdAt: rec.createdAt,
});
s.records.set(rec.id, rec);
}
}
s.spans = coalesce(s.spans);
if (artifact.attributions.length > 0) s.hadAttributions = true;
}
this.render(document);
}
/** Shared-watcher entry point (extension.ts): a sidecar changed externally. */
handleExternalSidecarChange(uri: vscode.Uri): void {
for (const s of this.docs.values()) {
if (this.store.sidecarPath(s.docPath) === uri.fsPath) {
const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === s.docPath);
if (doc) this.loadAll(doc);
}
}
}
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
const docPath = this.keyOf(e.document);
if (!e.document.isDirty && this.matchesDisk(e.document)) {
// Disk sync (revert / external reload): buffer now equals the file on
// disk — re-resolve, never attribute (PUC-4). A real edit can also
// arrive with isDirty still false (VS Code flips the flag after the
// change event), but then the buffer no longer matches the disk.
this.loadAll(e.document);
return;
}
const s = this.state(docPath);
// An undo/redo is history navigation, NOT authorship (#38): reconcile span
// geometry but never freshly attribute the re-inserted text to the current
// author — otherwise restored baseline text (or reverted Claude text) is
// falsely colored human in the preview. A seam edit is always a forward
// apply, so undo/redo also bypasses seam matching.
const isUndoRedo =
e.reason === vscode.TextDocumentChangeReason.Undo ||
e.reason === vscode.TextDocumentChangeReason.Redo;
// One applyEdit = one change event, but the host may deliver a seam edit
// as SEVERAL minimal hunks (word-level diffing). Match the EVENT's net
// effect against the registry; on a hit the agent owns its FULL intended
// replacement (INV-9) — apply it as ONE algebra edit, not per hunk.
const hit = isUndoRedo
? null
: this.pending.matchEvent(
docPath,
e.contentChanges.map((c) => ({
start: c.rangeOffset,
end: c.rangeOffset + c.rangeLength,
newLength: c.text.length,
})),
);
if (hit) {
const full = hit.full ?? { start: hit.start, end: hit.end, newLength: hit.newText.length };
s.spans = applyChange(s.spans, full, hit.provenance, {
newId: () => newId("at"),
now: () => new Date().toISOString(),
turnId: hit.turnId,
});
} else {
// Sort descending by offset so earlier changes don't invalidate later offsets
// (VS Code's order is undocumented; defensive sort is the safe guarantee).
for (const change of [...e.contentChanges].sort((a, b) => b.rangeOffset - a.rangeOffset)) {
const edit = {
start: change.rangeOffset,
end: change.rangeOffset + change.rangeLength,
newLength: change.text.length,
};
s.spans = applyChange(
s.spans,
edit,
this.currentAuthor(),
{
newId: () => newId("at"),
now: () => new Date().toISOString(),
},
!isUndoRedo,
);
}
}
if (s.spans.length > 0) s.hadAttributions = true;
this.render(e.document);
}
/**
* True when the document buffer is byte-identical to the file on disk.
* Fires only on `!isDirty` change events — first change after every clean
* state (frequent under `files.autoSave: afterDelay`) and reverts/reloads;
* O(file size) sync read. A size pre-check avoids the full read in the
* common case: if the byte lengths differ (accounting for a possible 3-byte
* UTF-8 BOM that `document.getText()` never includes) we return early. When
* a BOM is present the disk read is stripped before comparing so a genuine
* revert does not clobber attribution.
*/
private matchesDisk(document: vscode.TextDocument): boolean {
try {
const bufLen = Buffer.byteLength(document.getText(), "utf8");
const stat = fs.statSync(document.uri.fsPath);
// Allow size === bufLen (no BOM) OR size === bufLen + 3 (UTF-8 BOM).
if (stat.size !== bufLen && stat.size !== bufLen + 3) return false;
const disk = fs.readFileSync(document.uri.fsPath, "utf8").replace(/^/, "");
return disk === document.getText();
} catch {
// Unreadable/missing file: treat as a real edit (attribute), per
// fail-open honesty — misclassifying a sync as an edit is recoverable.
return false;
}
}
// ---- the seam (INV-9) ---------------------------------------------------------------
/**
* The ONLY machine-edit ingress: register the exact expected change, then
* apply it as a WorkspaceEdit. Returns false (applying nothing) on a stale
* document version or a rejected edit — never partial-applies (spec §6.9).
* The pending edit is unregistered unconditionally afterwards (a no-op when
* `match` already consumed it), so a no-op edit never leaks a registration.
* The replace is self-minimized (common prefix/suffix trimmed) to mirror the
* host's WorkspaceEdit diff-minimization, so registered == applied == delivered.
* Callers issuing concurrent edits on the same document must serialize them or
* pass `expectedVersion` (offsets are computed against the call-time snapshot).
*/
async applyAgentEdit(
document: vscode.TextDocument,
range: vscode.Range,
newText: string,
provenance: Provenance,
opts?: { expectedVersion?: number; turnId?: string; landBaseline?: boolean },
): Promise<boolean> {
if (!this.isTracked(document)) return false;
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
const docPath = this.keyOf(document);
const startOffset = document.offsetAt(range.start);
const endOffset = document.offsetAt(range.end);
const oldText = document.getText(range);
const { prefix, suffix } = minimizeReplace(oldText, newText);
const minStart = startOffset + prefix;
const minEnd = endOffset - suffix;
const minText = newText.slice(prefix, newText.length - suffix);
if (minStart === minEnd && minText.length === 0) {
// Replacement equals the existing text — a no-op; nothing to attribute.
return true;
}
const pendingEdit = {
docPath,
start: minStart,
end: minEnd,
newText: minText,
provenance,
turnId: opts?.turnId,
full: { start: startOffset, end: endOffset, newLength: newText.length },
};
this.pending.register(pendingEdit);
const we = new vscode.WorkspaceEdit();
we.replace(document.uri, new vscode.Range(document.positionAt(minStart), document.positionAt(minEnd)), minText);
const ok = await vscode.workspace.applyEdit(we);
const removed = this.pending.unregister(pendingEdit);
if (ok && removed) {
// workspace.applyEdit resolves AFTER onDidChangeTextDocument is dispatched
// synchronously to listeners, so a matching change event should have already
// consumed the registration before we reach here. If it is still present,
// the host minimized the diff differently than we predicted — attribution
// may be wrong for this edit (INV-9).
this.output.appendLine(
"WARN: seam edit applied but its change event never matched the registration " +
"(host minimized differently?) — the edit may be mis-attributed (INV-9).",
);
}
if (ok && opts?.landBaseline !== false) {
// F6 (INV-18, retired as a baseline advance — spec §6.4/INV-7): fire the
// machine-landing signal. F12 (INV-48) suppresses this for optimistic
// apply: the proposed text is in the buffer but the change stays PENDING
// until accept.
this.applyEmitter.fire({ document });
}
return ok;
}
/**
* F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text — used
* by finalize-in-place, where the proposed text already landed in the buffer via
* optimistic apply (`landBaseline:false`) and accept marks the landing so the
* now-accepted change is recorded (INV-7: the baseline itself no longer
* advances on landing — it advances only on commit / "Mark Changes as
* Reviewed").
*/
signalLanded(document: vscode.TextDocument): void {
if (this.isTracked(document)) this.applyEmitter.fire({ document });
}
// ---- PUC-4: persistence on save ----------------------------------------------------
private onDidSave(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
if (this.guard.isReadOnly(this.keyOf(document))) return;
const docPath = this.keyOf(document);
const s = this.docs.get(docPath);
// Allow save when hadAttributions is true even if spans/orphans are now empty:
// that means the user deliberately deleted all attributed text, and we must
// persist a.attributions=[] so stale records don't return as phantom orphans.
if (!s || (!s.hadAttributions && s.spans.length === 0 && s.orphans.length === 0)) return;
const text = document.getText();
const now = new Date().toISOString();
const records: AttributionRecord[] = [];
const anchorOf = new Map<string, OffsetRange>();
for (const span of s.spans) {
const prev = s.records.get(span.id);
const rec: AttributionRecord = {
id: span.id,
anchorId: prev?.anchorId ?? newId("a"),
author: span.author,
createdAt: span.createdAt,
updatedAt: prev ? prev.updatedAt : now,
...(span.turnId !== undefined ? { turnId: span.turnId } : {}),
};
anchorOf.set(rec.anchorId, { start: span.start, end: span.end });
records.push(rec);
s.records.set(span.id, rec);
}
this.store.update(docPath, (a) => {
for (const rec of records) {
const range = anchorOf.get(rec.anchorId)!;
const fp = buildFingerprint(text, range);
const prevFp = a.anchors[rec.anchorId]?.fingerprint;
if (prevFp && JSON.stringify(prevFp) !== JSON.stringify(fp)) rec.updatedAt = now;
a.anchors[rec.anchorId] = { fingerprint: fp };
}
// Orphans ride along unchanged (recoverable, spec §6.9): their records
// stay in attributions[], so update()'s prune keeps their anchors too.
a.attributions = [...records, ...s.orphans];
});
}
// ---- PUC-5: rendering ----------------------------------------------------------------
private renderActive(): void {
const editor = vscode.window.activeTextEditor;
if (editor) this.render(editor.document);
}
private render(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
const s = this.docs.get(this.keyOf(document));
if (document === vscode.window.activeTextEditor?.document) {
this.renderStatus(s);
}
}
private renderStatus(s: DocAttribution | undefined): void {
const n = s?.orphans.length ?? 0;
if (n === 0) {
this.statusItem.hide();
return;
}
this.statusItem.text = `$(warning) ${n} orphaned attribution${n === 1 ? "" : "s"}`;
this.statusItem.tooltip = "Cowriting: attribution anchors that no longer resolve (see output channel)";
this.statusItem.show();
this.output.clear();
for (const o of s!.orphans) this.output.appendLine(`orphaned ${o.id} (${o.author.kind}:${o.author.id})`);
}
// ---- test-facing surface ---------------------------------------------------------------
getSpans(docPath: string): RenderedSpan[] {
const s = this.docs.get(docPath);
if (!s) return [];
return s.spans.map((sp) => ({
id: sp.id,
authorKind: sp.author.kind,
authorId: sp.author.id,
turnId: sp.turnId,
range: { start: sp.start, end: sp.end },
}));
}
getOrphanCount(docPath: string): number {
return this.docs.get(docPath)?.orphans.length ?? 0;
}
/**
* F9: the document's live attribution as authorship spans for the preview —
* current-buffer char ranges mapped to author kind (agent→claude). Computes
* the document key internally, so callers pass a TextDocument, not the key.
*/
spansFor(document: vscode.TextDocument): AuthorSpan[] {
return this.getSpans(this.keyOf(document)).map((s) => ({
start: s.range.start,
end: s.range.end,
author: s.authorKind === "agent" ? "claude" : "human",
}));
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}