a36353d041
Task 4 gated every surface on CoeditingRegistry but only wired ThreadController/EditorProposalController to registry.onDidChange for restore-on-enter — ProposalController.renderAll and AttributionController.loadAll were never called on the enter transition, so a document's FIRST enter with pre-existing sidecar proposals/attribution showed threads but not proposal decorations/CodeLens or committed author-coloring until a later edit happened to fire them (a gap Task 4's own report flagged as known and unfixed). Both controllers now subscribe to registry.onDidChange in their constructors and call renderAll/loadAll on entry only (both already self-gate on isCoediting; exit stays hide-only, mirroring ThreadController). Construction order in extension.ts (attribution before proposals before EditorProposalController) keeps the same-tick read order correct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
441 lines
19 KiB
TypeScript
441 lines
19 KiB
TypeScript
/**
|
||
* 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) 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();
|
||
}
|
||
}
|