Files
vscode-cowriting-plugin/src/attributionController.ts
T

448 lines
20 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";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** 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,
private readonly registry: CoeditingRegistry,
) {
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()),
// PUC-7 restore-on-enter: `loadAll` is the only path that (re)populates
// s.spans/s.orphans from the sidecar — without this, a document's FIRST
// entry into coediting (registry.enter(), extension.ts) never runs it,
// so pre-existing committed authorship never surfaces until the next
// edit. Mirrors ThreadController's own registry subscription; `loadAll`
// already gates on isCoediting, and exit intentionally does nothing here
// (no data to wipe — EditorProposalController's own gate clears the
// decorations that read spansFor). Registered before ProposalController/
// EditorProposalController (construction order in extension.ts) so their
// renders see fresh spans on the same enter transition.
this.registry.onDidChange(({ uri, coediting }) => {
if (!coediting) {
// Finding 5 (final whole-branch review): exit hides the status-bar
// item too — otherwise a stale "N orphaned attributions" warning
// for a doc that's no longer even coediting stays pinned in the bar
// until some unrelated render happens to fire for it.
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
return;
}
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.loadAll(doc);
}),
);
}
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;
if (!this.registry.isCoediting(document.uri)) 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;
if (!this.registry.isCoediting(e.document.uri)) 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();
}
}