feat: VersionGuard — newer-major sidecars are read-only with one warning (F5 SLICE-2, INV-16/PUC-4, #14)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ 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";
|
||||
|
||||
/** Test-facing snapshot of live attribution state for a document. */
|
||||
export interface RenderedSpan {
|
||||
@@ -62,7 +63,11 @@ export class AttributionController implements vscode.Disposable {
|
||||
private readonly output = vscode.window.createOutputChannel("Cowriting Attribution");
|
||||
private visible = true;
|
||||
|
||||
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
|
||||
constructor(
|
||||
private readonly store: CoauthorStore,
|
||||
private readonly rootDir: string,
|
||||
private readonly guard: VersionGuard,
|
||||
) {
|
||||
this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output);
|
||||
this.disposables.push(
|
||||
vscode.commands.registerCommand("cowriting.toggleAttribution", () => this.toggle()),
|
||||
@@ -277,6 +282,7 @@ export class AttributionController implements vscode.Disposable {
|
||||
|
||||
private onDidSave(document: vscode.TextDocument): void {
|
||||
if (!this.isTracked(document)) return;
|
||||
if (this.guard.isReadOnly(this.docPathOf(document.uri))) return;
|
||||
const docPath = this.docPathOf(document.uri);
|
||||
const s = this.docs.get(docPath);
|
||||
// Allow save when hadAttributions is true even if spans/orphans are now empty:
|
||||
|
||||
+9
-4
@@ -5,6 +5,7 @@ import { ThreadController } from "./threadController";
|
||||
import { AttributionController } from "./attributionController";
|
||||
import { ProposalController } from "./proposalController";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import { VersionGuard } from "./versionGuard";
|
||||
|
||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||
|
||||
@@ -12,6 +13,7 @@ export interface CowritingApi {
|
||||
threadController: ThreadController;
|
||||
attributionController: AttributionController;
|
||||
proposalController: ProposalController;
|
||||
versionGuard: VersionGuard;
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||
@@ -67,15 +69,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
return undefined;
|
||||
}
|
||||
const store = new CoauthorStore(root);
|
||||
const threadController = new ThreadController(store, root);
|
||||
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
|
||||
// warning per doc across the three co-owning controllers.
|
||||
const versionGuard = new VersionGuard(store);
|
||||
const threadController = new ThreadController(store, root, versionGuard);
|
||||
context.subscriptions.push(threadController);
|
||||
|
||||
// --- F3: live attribution (Feature #6) ---
|
||||
const attributionController = new AttributionController(store, root);
|
||||
const attributionController = new AttributionController(store, root, versionGuard);
|
||||
context.subscriptions.push(attributionController);
|
||||
|
||||
// --- F4: propose/accept (Feature #12) ---
|
||||
const proposalController = new ProposalController(store, attributionController, root);
|
||||
const proposalController = new ProposalController(store, attributionController, root, versionGuard);
|
||||
context.subscriptions.push(proposalController);
|
||||
|
||||
// One SHARED sidecar watcher for both controllers; self-writes are
|
||||
@@ -228,7 +233,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
||||
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
|
||||
|
||||
return { threadController, attributionController, proposalController };
|
||||
return { threadController, attributionController, proposalController, versionGuard };
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Pro
|
||||
import { resolve, shift, type OffsetRange } from "./anchorer";
|
||||
import { addProposal, proposalBody, removeProposal } from "./proposalModel";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
|
||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||
export interface RenderedProposal {
|
||||
@@ -53,6 +54,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
private readonly store: CoauthorStore,
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly rootDir: string,
|
||||
private readonly guard: VersionGuard,
|
||||
) {
|
||||
// No commentingRangeProvider: humans never open proposal threads by hand —
|
||||
// proposals are born of machine turns only (INV-12 keeps decisions human).
|
||||
@@ -103,6 +105,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
opts?: { turnId?: string; instruction?: string },
|
||||
): Promise<string | undefined> {
|
||||
if (!this.isTracked(document)) return undefined;
|
||||
if (this.guard.isReadOnly(this.docPathOf(document.uri))) return undefined;
|
||||
const docPath = this.docPathOf(document.uri);
|
||||
let proposalId: string | undefined;
|
||||
this.store.update(docPath, (a) => {
|
||||
@@ -137,6 +140,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
}
|
||||
|
||||
private async accept(state: DocState, proposal: Proposal): Promise<boolean> {
|
||||
if (this.guard.isReadOnly(state.docPath)) return false;
|
||||
const document = this.openDoc(state);
|
||||
if (!document) return false;
|
||||
// INV-11: the fingerprint-guard — exact re-resolve at decision time.
|
||||
@@ -167,6 +171,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
}
|
||||
|
||||
private reject(state: DocState, proposal: Proposal): void {
|
||||
if (this.guard.isReadOnly(state.docPath)) return;
|
||||
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
||||
const document = this.openDoc(state);
|
||||
if (document) this.renderAll(document);
|
||||
|
||||
+10
-1
@@ -13,6 +13,7 @@ import { emptyArtifact, type Artifact, type Provenance } from "./model";
|
||||
import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer";
|
||||
import { gitUserEmail } from "./identity";
|
||||
import { addThread, appendMessage, setStatus } from "./threadModel";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
|
||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||
export interface RenderedThread {
|
||||
@@ -39,7 +40,11 @@ export class ThreadController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly docs = new Map<string, DocState>(); // keyed by docPath
|
||||
|
||||
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
|
||||
constructor(
|
||||
private readonly store: CoauthorStore,
|
||||
private readonly rootDir: string,
|
||||
private readonly guard: VersionGuard,
|
||||
) {
|
||||
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
||||
this.controller.commentingRangeProvider = {
|
||||
provideCommentingRanges: (document) => {
|
||||
@@ -108,6 +113,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined;
|
||||
if (this.guard.isReadOnly(this.docPathOf(editor.document.uri))) return undefined;
|
||||
const document = editor.document;
|
||||
const state = this.ensureState(document);
|
||||
const offsets: OffsetRange = {
|
||||
@@ -127,6 +133,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
const threadId = this.threadIdOf(r.thread);
|
||||
const state = this.stateOfThread(r.thread);
|
||||
if (!threadId || !state) return;
|
||||
if (this.guard.isReadOnly(state.docPath)) return;
|
||||
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text });
|
||||
this.persist(state);
|
||||
this.refreshComments(r.thread, state, threadId);
|
||||
@@ -136,6 +143,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
const threadId = this.threadIdOf(vsThread);
|
||||
const state = this.stateOfThread(vsThread);
|
||||
if (!threadId || !state) return;
|
||||
if (this.guard.isReadOnly(state.docPath)) return;
|
||||
setStatus(state.artifact, threadId, status);
|
||||
this.persist(state);
|
||||
vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
|
||||
@@ -198,6 +206,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
private onDidSave(document: vscode.TextDocument): void {
|
||||
const state = this.docs.get(this.docPathOf(document.uri));
|
||||
if (!state || state.live.size === 0) return;
|
||||
if (this.guard.isReadOnly(state.docPath)) return;
|
||||
const text = document.getText();
|
||||
let changed = false;
|
||||
for (const thread of state.artifact.threads) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* VersionGuard — INV-16's editor face (spec §6.2/§6.4, PUC-4): a sidecar whose
|
||||
* schemaVersion major is newer than this extension understands is READ-ONLY.
|
||||
* Controllers render best-effort but skip every store.update path; the user is
|
||||
* warned ONCE per document. One shared instance (extension.ts) because the
|
||||
* sidecar is co-owned by three controllers — one warning, not three. The
|
||||
* store's update() throw is the backstop; this gate is the UX.
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
import { SCHEMA_VERSION, isNewerMajor } from "./model";
|
||||
import type { CoauthorStore } from "./store";
|
||||
|
||||
export class VersionGuard {
|
||||
private readonly warned = new Set<string>();
|
||||
|
||||
constructor(private readonly store: CoauthorStore) {}
|
||||
|
||||
/** True → skip the write path (and warn once per doc). */
|
||||
isReadOnly(docPath: string): boolean {
|
||||
const artifact = this.store.load(docPath);
|
||||
if (!artifact || !isNewerMajor(artifact)) return false;
|
||||
if (!this.warned.has(docPath)) {
|
||||
this.warned.add(docPath);
|
||||
void vscode.window.showWarningMessage(
|
||||
`Cowriting: ${docPath}'s coauthoring sidecar is schemaVersion ${artifact.schemaVersion} — newer than this extension understands (${SCHEMA_VERSION}). Showing what it can; writing nothing (INV-16). Upgrade the extension to edit the record.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Test-facing: did the newer-major warning fire for this doc? */
|
||||
wasWarned(docPath: string): boolean {
|
||||
return this.warned.has(docPath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user