Merge pull request 'F5: cross-rung format contract + round-trip (#14)' (#15) from feature/14-cross-rung-format into main
This commit was merged in pull request #15.
This commit is contained in:
@@ -98,6 +98,39 @@ CI.
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-propose-accept.md`.
|
||||
Live smoke: [`docs/MANUAL-SMOKE-F4.md`](docs/MANUAL-SMOKE-F4.md).
|
||||
|
||||
## F5 — Cross-rung format + round-trip (Feature #14)
|
||||
|
||||
The `.threads/` sidecar is the **published cross-rung contract**: any rung of
|
||||
the ladder (this editor → Gitea substrate → rfc-app) reads and writes the same
|
||||
record per the contract; git push/pull is the transport, no re-homing ever.
|
||||
|
||||
- **Normative contract (INV-14):**
|
||||
`vscode-cowriting-plugin-content/specs/coauthoring-sidecar-contract.md` —
|
||||
format changes land there first, then schema, then code.
|
||||
- **Machine-checkable half:**
|
||||
[`schemas/coauthoring-sidecar.schema.json`](schemas/coauthoring-sidecar.schema.json);
|
||||
validate any sidecar from any rung with
|
||||
`node scripts/validate-sidecar.mjs <file>`. The unit suite validates every
|
||||
serialized artifact — contract drift fails CI.
|
||||
- **Identity crosses rungs:** `Provenance.email` (git's own join key —
|
||||
populated from the workspace git config when available) and
|
||||
`agent.onBehalfOf` (who the machine acted for).
|
||||
- **Writers preserve unknown fields (INV-15):** rewriting a sidecar never
|
||||
destroys another rung's data — unknown keys survive, after known keys,
|
||||
sorted.
|
||||
- **Newer-major sidecars are read-only (INV-16):** the editor renders what it
|
||||
understands, warns once, and writes nothing (the store refuses as a
|
||||
backstop).
|
||||
- **Deterministic merge (INV-17):** `src/mergeArtifacts.ts` — union-by-id,
|
||||
documented tie-breaks, every resolved divergence surfaced in `conflicts`.
|
||||
- **The round-trip is proven, not asserted:**
|
||||
[`scripts/crossrung-reply.mjs`](scripts/crossrung-reply.mjs) is a
|
||||
self-contained conforming foreign writer (the Gitea-rung stand-in); host E2E
|
||||
drives editor-thread → stand-in reply → external-change → the reply renders
|
||||
in the thread.
|
||||
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`.
|
||||
|
||||
## Develop
|
||||
|
||||
- `npm run watch` — rebuild on change.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+1
@@ -16,6 +16,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/vscode": "^1.90.0",
|
||||
"@vscode/test-electron": "^2.4.0",
|
||||
"ajv": "^8.20.0",
|
||||
"esbuild": "^0.23.0",
|
||||
"glob": "^11.0.0",
|
||||
"mocha": "^10.7.0",
|
||||
|
||||
+73
-16
@@ -10,9 +10,13 @@
|
||||
"vscode": "^1.90.0",
|
||||
"node": ">=22"
|
||||
},
|
||||
"categories": ["Other"],
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"main": "./out/extension.cjs",
|
||||
"activationEvents": ["onStartupFinished"],
|
||||
"activationEvents": [
|
||||
"onStartupFinished"
|
||||
],
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
@@ -25,22 +29,70 @@
|
||||
"title": "Cowriting: Add Coauthoring Thread on Selection",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{ "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" },
|
||||
{ "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
|
||||
{ "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" },
|
||||
{ "command": "cowriting.toggleAttribution", "title": "Toggle Attribution", "category": "Cowriting" },
|
||||
{ "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" },
|
||||
{ "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", "category": "Cowriting" },
|
||||
{ "command": "cowriting.acceptProposal", "title": "✓ Accept Proposal", "category": "Cowriting" },
|
||||
{ "command": "cowriting.rejectProposal", "title": "✗ Reject Proposal", "category": "Cowriting" },
|
||||
{ "command": "cowriting.proposeAgentEdit", "title": "Propose Agent Edit (internal seam)", "category": "Cowriting" }
|
||||
{
|
||||
"command": "cowriting.reply",
|
||||
"title": "Reply",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.resolveThread",
|
||||
"title": "Resolve Thread",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.reopenThread",
|
||||
"title": "Reopen Thread",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.toggleAttribution",
|
||||
"title": "Toggle Attribution",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.applyAgentEdit",
|
||||
"title": "Apply Agent Edit (internal seam)",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"title": "Ask Claude to Edit Selection",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.acceptProposal",
|
||||
"title": "✓ Accept Proposal",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.rejectProposal",
|
||||
"title": "✗ Reject Proposal",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.proposeAgentEdit",
|
||||
"title": "Propose Agent Edit (internal seam)",
|
||||
"category": "Cowriting"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"commandPalette": [
|
||||
{ "command": "cowriting.applyAgentEdit", "when": "false" },
|
||||
{ "command": "cowriting.proposeAgentEdit", "when": "false" },
|
||||
{ "command": "cowriting.acceptProposal", "when": "false" },
|
||||
{ "command": "cowriting.rejectProposal", "when": "false" }
|
||||
{
|
||||
"command": "cowriting.applyAgentEdit",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.proposeAgentEdit",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.acceptProposal",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.rejectProposal",
|
||||
"when": "false"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
@@ -55,7 +107,11 @@
|
||||
}
|
||||
],
|
||||
"comments/commentThread/context": [
|
||||
{ "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" }
|
||||
{
|
||||
"command": "cowriting.reply",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.threads"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/title": [
|
||||
{
|
||||
@@ -99,6 +155,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/vscode": "^1.90.0",
|
||||
"@vscode/test-electron": "^2.4.0",
|
||||
"ajv": "^8.20.0",
|
||||
"esbuild": "^0.23.0",
|
||||
"glob": "^11.0.0",
|
||||
"mocha": "^10.7.0",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.benstull.org/benstull/vscode-cowriting-plugin/schemas/coauthoring-sidecar.schema.json",
|
||||
"title": "Coauthoring sidecar artifact — cross-rung contract, schemaVersion 1",
|
||||
"description": "Machine-checkable half of the cross-rung contract (vscode-cowriting-plugin-content/specs/coauthoring-sidecar-contract.md — the prose contract is normative, INV-14). Unknown fields are deliberately allowed at every level: minor versions are additive and conforming writers preserve fields they do not recognize (INV-15/INV-16).",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"],
|
||||
"properties": {
|
||||
"schemaVersion": { "type": "integer", "minimum": 1 },
|
||||
"document": {
|
||||
"type": "object",
|
||||
"required": ["path"],
|
||||
"properties": { "path": { "type": "string", "minLength": 1 } }
|
||||
},
|
||||
"anchors": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/anchor" }
|
||||
},
|
||||
"threads": { "type": "array", "items": { "$ref": "#/$defs/thread" } },
|
||||
"attributions": { "type": "array", "items": { "$ref": "#/$defs/attribution" } },
|
||||
"proposals": { "type": "array", "items": { "$ref": "#/$defs/proposal" } }
|
||||
},
|
||||
"$defs": {
|
||||
"fingerprint": {
|
||||
"type": "object",
|
||||
"required": ["text", "before", "after", "lineHint"],
|
||||
"properties": {
|
||||
"text": { "type": "string" },
|
||||
"before": { "type": "string" },
|
||||
"after": { "type": "string" },
|
||||
"lineHint": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"anchor": {
|
||||
"type": "object",
|
||||
"required": ["fingerprint"],
|
||||
"properties": { "fingerprint": { "$ref": "#/$defs/fingerprint" } }
|
||||
},
|
||||
"onBehalfOf": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": { "id": { "type": "string" }, "email": { "type": "string" } }
|
||||
},
|
||||
"agentPayload": {
|
||||
"type": "object",
|
||||
"required": ["sdk", "model", "sessionId"],
|
||||
"properties": {
|
||||
"sdk": { "type": "string" },
|
||||
"model": { "type": "string" },
|
||||
"sessionId": { "type": "string" },
|
||||
"onBehalfOf": { "$ref": "#/$defs/onBehalfOf" }
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"type": "object",
|
||||
"required": ["kind", "id"],
|
||||
"properties": {
|
||||
"kind": { "enum": ["human", "agent"] },
|
||||
"id": { "type": "string" },
|
||||
"email": { "type": "string" },
|
||||
"agent": { "$ref": "#/$defs/agentPayload" }
|
||||
},
|
||||
"if": { "properties": { "kind": { "const": "agent" } } },
|
||||
"then": { "required": ["kind", "id", "agent"] }
|
||||
},
|
||||
"message": {
|
||||
"type": "object",
|
||||
"required": ["id", "author", "body", "createdAt"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"author": { "$ref": "#/$defs/provenance" },
|
||||
"body": { "type": "string" },
|
||||
"createdAt": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"thread": {
|
||||
"type": "object",
|
||||
"required": ["id", "anchorId", "status", "messages"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"anchorId": { "type": "string" },
|
||||
"status": { "enum": ["open", "resolved"] },
|
||||
"messages": { "type": "array", "items": { "$ref": "#/$defs/message" } }
|
||||
}
|
||||
},
|
||||
"attribution": {
|
||||
"type": "object",
|
||||
"required": ["id", "anchorId", "author", "createdAt", "updatedAt"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"anchorId": { "type": "string" },
|
||||
"author": { "$ref": "#/$defs/provenance" },
|
||||
"createdAt": { "type": "string" },
|
||||
"updatedAt": { "type": "string" },
|
||||
"turnId": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"proposal": {
|
||||
"type": "object",
|
||||
"required": ["id", "anchorId", "replacement", "author", "createdAt"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"anchorId": { "type": "string" },
|
||||
"replacement": { "type": "string" },
|
||||
"author": { "$ref": "#/$defs/provenance" },
|
||||
"createdAt": { "type": "string" },
|
||||
"turnId": { "type": "string" },
|
||||
"instruction": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
// crossrung-reply — the Gitea-rung STAND-IN writer (F5 spec §6.2, fork e):
|
||||
// exactly what a forge-side surface does to the record, as a local process.
|
||||
// Conforming-writer behavior per the contract (vscode-cowriting-plugin-content/
|
||||
// specs/coauthoring-sidecar-contract.md §5): validate → refuse newer majors →
|
||||
// append a Message (fresh uuid, ISO-8601 UTC, identity w/ email) →
|
||||
// stable-serialize (known-key order, unknowns after sorted, 2-space, trailing
|
||||
// newline) → validate again → write.
|
||||
//
|
||||
// This deliberately RE-IMPLEMENTS the contract's serialization instead of
|
||||
// importing the extension's serializer: it is executable documentation of how
|
||||
// a second rung behaves. test/crossrungReply.test.ts pins byte-fidelity
|
||||
// against the reference serializer — the contract-drift tripwire.
|
||||
//
|
||||
// usage:
|
||||
// node scripts/crossrung-reply.mjs <sidecar.json> <threadId> <body> \
|
||||
// [--author-id id] [--author-email email]
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import Ajv from "ajv/dist/2020.js";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const schema = JSON.parse(readFileSync(path.join(here, "..", "schemas", "coauthoring-sidecar.schema.json"), "utf8"));
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
const checks = ajv.compile(schema);
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`crossrung-reply: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
function validateOrFail(data, when) {
|
||||
if (!checks(data)) {
|
||||
fail(
|
||||
`${when} validation failed:\n` +
|
||||
(checks.errors ?? []).map((e) => ` ${e.instancePath || "/"} ${e.message}`).join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- args ---
|
||||
const [file, threadId, body] = process.argv.slice(2, 5);
|
||||
if (!file || !threadId || body === undefined) {
|
||||
console.error("usage: crossrung-reply.mjs <sidecar.json> <threadId> <body> [--author-id id] [--author-email email]");
|
||||
process.exit(2);
|
||||
}
|
||||
const flags = process.argv.slice(5);
|
||||
const flagVal = (name) => {
|
||||
const i = flags.indexOf(name);
|
||||
return i >= 0 ? flags[i + 1] : undefined;
|
||||
};
|
||||
const authorId = flagVal("--author-id") ?? "crossrung";
|
||||
const authorEmail = flagVal("--author-email");
|
||||
|
||||
// --- contract serialization (independent implementation: contract §2) ---
|
||||
const ORDER = {
|
||||
root: ["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"],
|
||||
document: ["path"],
|
||||
anchor: ["fingerprint"],
|
||||
fingerprint: ["text", "before", "after", "lineHint"],
|
||||
thread: ["id", "anchorId", "status", "messages"],
|
||||
message: ["id", "author", "body", "createdAt"],
|
||||
provenance: ["kind", "id", "email", "agent"],
|
||||
agent: ["sdk", "model", "sessionId", "onBehalfOf"],
|
||||
onBehalfOf: ["id", "email"],
|
||||
attribution: ["id", "anchorId", "author", "createdAt", "updatedAt", "turnId"],
|
||||
proposal: ["id", "anchorId", "replacement", "author", "createdAt", "turnId", "instruction"],
|
||||
};
|
||||
// known keys first (contract order, present ones only), unknown keys after, sorted
|
||||
function ordered(obj, kind, mapKnown = {}) {
|
||||
const out = {};
|
||||
const known = ORDER[kind] ?? [];
|
||||
for (const k of known) if (k in obj) out[k] = k in mapKnown ? mapKnown[k](obj[k]) : obj[k];
|
||||
for (const k of Object.keys(obj).filter((k) => !known.includes(k)).sort()) out[k] = obj[k];
|
||||
return out;
|
||||
}
|
||||
const provenance = (p) =>
|
||||
ordered(p, "provenance", {
|
||||
agent: (ag) => ordered(ag, "agent", { onBehalfOf: (o) => ordered(o, "onBehalfOf") }),
|
||||
});
|
||||
function canonicalize(a) {
|
||||
return ordered(a, "root", {
|
||||
document: (d) => ordered(d, "document"),
|
||||
anchors: (anchors) =>
|
||||
Object.fromEntries(
|
||||
Object.keys(anchors)
|
||||
.sort()
|
||||
.map((k) => [k, ordered(anchors[k], "anchor", { fingerprint: (fp) => ordered(fp, "fingerprint") })]),
|
||||
),
|
||||
threads: (ts) =>
|
||||
ts.map((t) => ordered(t, "thread", { messages: (ms) => ms.map((m) => ordered(m, "message", { author: provenance })) })),
|
||||
attributions: (ats) => ats.map((at) => ordered(at, "attribution", { author: provenance })),
|
||||
proposals: (ps) => ps.map((p) => ordered(p, "proposal", { author: provenance })),
|
||||
});
|
||||
}
|
||||
const serialize = (a) => JSON.stringify(canonicalize(a), null, 2) + "\n";
|
||||
|
||||
// --- the write (contract §5 checklist) ---
|
||||
const data = JSON.parse(readFileSync(file, "utf8"));
|
||||
validateOrFail(data, "pre-write");
|
||||
if (data.schemaVersion > 1) {
|
||||
fail(`schemaVersion ${data.schemaVersion} is newer than this writer understands (1) — refusing to write (INV-16)`);
|
||||
}
|
||||
const thread = data.threads.find((t) => t.id === threadId);
|
||||
if (!thread) fail(`no thread ${threadId} in ${file}`);
|
||||
thread.messages.push({
|
||||
id: `m_${randomUUID()}`,
|
||||
author: { kind: "human", id: authorId, ...(authorEmail ? { email: authorEmail } : {}) },
|
||||
body,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
const out = serialize(data);
|
||||
validateOrFail(JSON.parse(out), "post-write");
|
||||
writeFileSync(file, out, "utf8");
|
||||
console.log(`appended reply to ${threadId} in ${file}`);
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
// validate-sidecar — rung-neutral conformance check (spec §6.4, PUC-5).
|
||||
// usage:
|
||||
// node scripts/validate-sidecar.mjs <sidecar.json>
|
||||
// exit 0 = valid, 1 = invalid (per-error JSON paths on stderr), 2 = usage/IO.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import Ajv from "ajv/dist/2020.js";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const schema = JSON.parse(readFileSync(path.join(here, "..", "schemas", "coauthoring-sidecar.schema.json"), "utf8"));
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error("usage: validate-sidecar.mjs <sidecar.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (err) {
|
||||
console.error(`cannot read/parse ${file}: ${err.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
const validate = ajv.compile(schema);
|
||||
if (validate(data)) {
|
||||
console.log(`OK ${file} (schemaVersion ${data.schemaVersion})`);
|
||||
process.exit(0);
|
||||
}
|
||||
for (const e of validate.errors ?? []) console.error(`${e.instancePath || "/"} ${e.message}`);
|
||||
process.exit(1);
|
||||
@@ -13,8 +13,10 @@ import * as vscode from "vscode";
|
||||
import { CoauthorStore } from "./store";
|
||||
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";
|
||||
|
||||
/** Test-facing snapshot of live attribution state for a document. */
|
||||
export interface RenderedSpan {
|
||||
@@ -61,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()),
|
||||
@@ -79,7 +85,8 @@ export class AttributionController implements vscode.Disposable {
|
||||
}
|
||||
private currentAuthor(): Provenance {
|
||||
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
|
||||
return { kind: "human", id };
|
||||
const email = gitUserEmail(this.rootDir);
|
||||
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
|
||||
}
|
||||
private state(docPath: string): DocAttribution {
|
||||
let s = this.docs.get(docPath);
|
||||
@@ -275,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 {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Cross-rung identity (F5, spec §6.3 fork c): `email` is git's own join key —
|
||||
* forges already map email → account. Resolved from the workspace git config,
|
||||
* cached per root, FAIL-OPEN: absence just omits the optional field and the
|
||||
* sidecar stays valid (spec §6.9). vscode-free, so it is unit-testable.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const cache = new Map<string, string | undefined>();
|
||||
|
||||
export function gitUserEmail(rootDir: string): string | undefined {
|
||||
if (!cache.has(rootDir)) {
|
||||
try {
|
||||
const out = execFileSync("git", ["config", "user.email"], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
cache.set(rootDir, out || undefined);
|
||||
} catch {
|
||||
cache.set(rootDir, undefined);
|
||||
}
|
||||
}
|
||||
return cache.get(rootDir);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* mergeArtifacts — the contract's merge semantics (INV-17) as a pure, vscode-
|
||||
* free reference implementation (contract §6). 2-way union-by-id; every
|
||||
* same-id divergence is resolved by the documented deterministic rules AND
|
||||
* surfaced in `conflicts` — never silently guessed (INV-1's spirit at the
|
||||
* artifact level). Refuses differing majors (INV-16: a rung never merges what
|
||||
* it can't fully read). Not wired into any UI — rungs and humans invoke it; a
|
||||
* .gitattributes merge driver is a later convenience (spec §1.7).
|
||||
*/
|
||||
import type { Artifact, Message, Thread } from "./model";
|
||||
|
||||
export interface MergeResult {
|
||||
merged: Artifact;
|
||||
/** ids (or root keys) whose divergence was rule-resolved — surfaced, never silent. */
|
||||
conflicts: string[];
|
||||
}
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
|
||||
/** Order-insensitive canonical form: all object keys sorted, no whitespace (contract §6). */
|
||||
function stableStringify(v: unknown): string {
|
||||
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
||||
if (v !== null && typeof v === "object") {
|
||||
const rec = v as Rec;
|
||||
return `{${Object.keys(rec)
|
||||
.sort()
|
||||
.map((k) => `${JSON.stringify(k)}:${stableStringify(rec[k])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
|
||||
const same = (a: unknown, b: unknown): boolean => stableStringify(a) === stableStringify(b);
|
||||
|
||||
/** Deterministic, symmetric tie-break: the lexicographically larger canonical form wins. */
|
||||
function lexLarger<T>(a: T, b: T): T {
|
||||
return stableStringify(a) >= stableStringify(b) ? a : b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union two record arrays by id: one-side-only records pass through, identical
|
||||
* records dedupe, same-id divergence is handed to `resolveDivergent` (which
|
||||
* owns both picking the winner and reporting the conflict). Ours-order first,
|
||||
* theirs-only appended in theirs-order.
|
||||
*/
|
||||
function unionById<T extends { id: string }>(ours: T[], theirs: T[], resolveDivergent: (o: T, t: T) => T): T[] {
|
||||
const theirsById = new Map(theirs.map((t) => [t.id, t]));
|
||||
const ourIds = new Set(ours.map((o) => o.id));
|
||||
const out: T[] = [];
|
||||
for (const o of ours) {
|
||||
const t = theirsById.get(o.id);
|
||||
out.push(t === undefined || same(o, t) ? o : resolveDivergent(o, t));
|
||||
}
|
||||
for (const t of theirs) if (!ourIds.has(t.id)) out.push(t);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-id threads: the shell (every field but `messages`) and the messages
|
||||
* merge independently (contract §6). Divergent shells tie-break and report the
|
||||
* thread id; messages union by id ordered by (createdAt, id), per-message
|
||||
* divergence tie-breaks and reports the message id. The forge reply-append
|
||||
* case merges cleanly by construction (fresh uuids, disjoint additions).
|
||||
*/
|
||||
function mergeThread(o: Thread, t: Thread, conflicts: string[]): Thread {
|
||||
const { messages: oMsgs, ...oShell } = o;
|
||||
const { messages: tMsgs, ...tShell } = t;
|
||||
let shell = oShell;
|
||||
if (!same(oShell, tShell)) {
|
||||
conflicts.push(o.id);
|
||||
shell = lexLarger(oShell, tShell);
|
||||
}
|
||||
const messages: Message[] = unionById(oMsgs, tMsgs, (a, b) => {
|
||||
conflicts.push(a.id);
|
||||
return lexLarger(a, b);
|
||||
}).sort((a, b) =>
|
||||
a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
|
||||
);
|
||||
return { ...shell, messages } as Thread;
|
||||
}
|
||||
|
||||
export function mergeArtifacts(ours: Artifact, theirs: Artifact): MergeResult {
|
||||
if (ours.schemaVersion !== theirs.schemaVersion) {
|
||||
throw new Error(
|
||||
`cannot merge differing schemaVersion majors: ${ours.schemaVersion} vs ${theirs.schemaVersion} (INV-16)`,
|
||||
);
|
||||
}
|
||||
const conflicts: string[] = [];
|
||||
|
||||
// anchors: union by key; divergent same key → tie-break, reported.
|
||||
const anchors: Artifact["anchors"] = { ...theirs.anchors, ...ours.anchors };
|
||||
for (const k of Object.keys(ours.anchors)) {
|
||||
const t = theirs.anchors[k];
|
||||
if (t !== undefined && !same(ours.anchors[k], t)) {
|
||||
conflicts.push(k);
|
||||
anchors[k] = lexLarger(ours.anchors[k], t);
|
||||
}
|
||||
}
|
||||
|
||||
const threads = unionById(ours.threads, theirs.threads, (o, t) => mergeThread(o, t, conflicts));
|
||||
|
||||
const attributions = unionById(ours.attributions, theirs.attributions, (o, t) => {
|
||||
conflicts.push(o.id);
|
||||
// newer updatedAt wins; equal updatedAt falls through to the tie-break.
|
||||
if (o.updatedAt !== t.updatedAt) return o.updatedAt > t.updatedAt ? o : t;
|
||||
return lexLarger(o, t);
|
||||
});
|
||||
|
||||
const proposals = unionById(ours.proposals, theirs.proposals, (o, t) => {
|
||||
conflicts.push(o.id);
|
||||
return lexLarger(o, t);
|
||||
});
|
||||
|
||||
// root: known sections merged above; document must agree; unknown root keys
|
||||
// union with the same tie-break + report (contract §6).
|
||||
const KNOWN_ROOT = new Set(["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"]);
|
||||
let document = ours.document;
|
||||
if (!same(ours.document, theirs.document)) {
|
||||
conflicts.push("document");
|
||||
document = lexLarger(ours.document, theirs.document);
|
||||
}
|
||||
const merged = { schemaVersion: ours.schemaVersion, document, anchors, threads, attributions, proposals } as Artifact &
|
||||
Rec;
|
||||
const oRec = ours as unknown as Rec;
|
||||
const tRec = theirs as unknown as Rec;
|
||||
for (const k of [...new Set([...Object.keys(oRec), ...Object.keys(tRec)])].sort()) {
|
||||
if (KNOWN_ROOT.has(k)) continue;
|
||||
const inO = k in oRec;
|
||||
const inT = k in tRec;
|
||||
if (inO && inT && !same(oRec[k], tRec[k])) {
|
||||
conflicts.push(k);
|
||||
merged[k] = lexLarger(oRec[k], tRec[k]);
|
||||
} else {
|
||||
merged[k] = inO ? oRec[k] : tRec[k];
|
||||
}
|
||||
}
|
||||
return { merged, conflicts };
|
||||
}
|
||||
+109
-22
@@ -25,10 +25,25 @@ export interface Anchor {
|
||||
fingerprint: Fingerprint;
|
||||
}
|
||||
|
||||
/** The reusable author field (spec §6.3). `agent` only present when kind=agent. */
|
||||
/**
|
||||
* The reusable author field (contract §3.3). `agent` only present when
|
||||
* kind=agent. F5 (spec §6.3, fork c): `email` is the cross-rung join key —
|
||||
* git's own identity model (forges map email → account); `onBehalfOf` records
|
||||
* the operator a machine acted for (rfc-app#46 §6.5 made data).
|
||||
*/
|
||||
export type Provenance =
|
||||
| { kind: "human"; id: string }
|
||||
| { kind: "agent"; id: string; agent: { sdk: string; model: string; sessionId: string } };
|
||||
| { kind: "human"; id: string; email?: string }
|
||||
| {
|
||||
kind: "agent";
|
||||
id: string;
|
||||
email?: string;
|
||||
agent: {
|
||||
sdk: string;
|
||||
model: string;
|
||||
sessionId: string;
|
||||
onBehalfOf?: { id: string; email?: string };
|
||||
};
|
||||
};
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
@@ -101,15 +116,63 @@ export function emptyArtifact(docPath: string): Artifact {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* INV-16 fail-safe: schemaVersion IS the major. A sidecar from a future major
|
||||
* is render-only — a conforming writer never rewrites what it can't fully read.
|
||||
*/
|
||||
export function isNewerMajor(a: Pick<Artifact, "schemaVersion">): boolean {
|
||||
return a.schemaVersion > SCHEMA_VERSION;
|
||||
}
|
||||
|
||||
/** Stable, generated id (spec §6.3). */
|
||||
export function newId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* INV-15 (round-trip preservation): after rebuilding the canonical known keys,
|
||||
* append any fields of `src` this writer does not recognize, sorted
|
||||
* lexicographically — no rung's writer may destroy another rung's data, and
|
||||
* placement stays deterministic (INV-2). Contract §2 rule 5 / §4 rule 3.
|
||||
*/
|
||||
function withUnknowns(known: Record<string, unknown>, src: unknown): Record<string, unknown> {
|
||||
const rec = src as Record<string, unknown>;
|
||||
for (const k of Object.keys(rec)
|
||||
.filter((k) => !(k in known))
|
||||
.sort()) {
|
||||
known[k] = rec[k];
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
function serializeProvenance(p: Provenance): Record<string, unknown> {
|
||||
return p.kind === "agent"
|
||||
? { kind: p.kind, id: p.id, agent: { sdk: p.agent.sdk, model: p.agent.model, sessionId: p.agent.sessionId } }
|
||||
: { kind: p.kind, id: p.id };
|
||||
const known: Record<string, unknown> = {
|
||||
kind: p.kind,
|
||||
id: p.id,
|
||||
...(p.email !== undefined ? { email: p.email } : {}),
|
||||
};
|
||||
if (p.kind === "agent") {
|
||||
known.agent = withUnknowns(
|
||||
{
|
||||
sdk: p.agent.sdk,
|
||||
model: p.agent.model,
|
||||
sessionId: p.agent.sessionId,
|
||||
...(p.agent.onBehalfOf !== undefined
|
||||
? {
|
||||
onBehalfOf: withUnknowns(
|
||||
{
|
||||
id: p.agent.onBehalfOf.id,
|
||||
...(p.agent.onBehalfOf.email !== undefined ? { email: p.agent.onBehalfOf.email } : {}),
|
||||
},
|
||||
p.agent.onBehalfOf,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
p.agent,
|
||||
);
|
||||
}
|
||||
return withUnknowns(known, p);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,44 +181,63 @@ function serializeProvenance(p: Provenance): Record<string, unknown> {
|
||||
* ends with a trailing newline.
|
||||
*/
|
||||
export function serializeArtifact(a: Artifact): string {
|
||||
const canonical = {
|
||||
const canonical = withUnknowns(
|
||||
{
|
||||
schemaVersion: a.schemaVersion,
|
||||
document: { path: a.document.path },
|
||||
document: withUnknowns({ path: a.document.path }, a.document),
|
||||
anchors: Object.fromEntries(
|
||||
Object.keys(a.anchors)
|
||||
.sort()
|
||||
.map((k) => [
|
||||
k,
|
||||
withUnknowns(
|
||||
{
|
||||
fingerprint: withUnknowns(
|
||||
{
|
||||
fingerprint: {
|
||||
text: a.anchors[k].fingerprint.text,
|
||||
before: a.anchors[k].fingerprint.before,
|
||||
after: a.anchors[k].fingerprint.after,
|
||||
lineHint: a.anchors[k].fingerprint.lineHint,
|
||||
},
|
||||
a.anchors[k].fingerprint,
|
||||
),
|
||||
},
|
||||
a.anchors[k],
|
||||
),
|
||||
]),
|
||||
),
|
||||
threads: a.threads.map((t) => ({
|
||||
threads: a.threads.map((t) =>
|
||||
withUnknowns(
|
||||
{
|
||||
id: t.id,
|
||||
anchorId: t.anchorId,
|
||||
status: t.status,
|
||||
messages: t.messages.map((m) => ({
|
||||
id: m.id,
|
||||
author: serializeProvenance(m.author),
|
||||
body: m.body,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
})),
|
||||
attributions: a.attributions.map((at) => ({
|
||||
messages: t.messages.map((m) =>
|
||||
withUnknowns(
|
||||
{ id: m.id, author: serializeProvenance(m.author), body: m.body, createdAt: m.createdAt },
|
||||
m,
|
||||
),
|
||||
),
|
||||
},
|
||||
t,
|
||||
),
|
||||
),
|
||||
attributions: a.attributions.map((at) =>
|
||||
withUnknowns(
|
||||
{
|
||||
id: at.id,
|
||||
anchorId: at.anchorId,
|
||||
author: serializeProvenance(at.author),
|
||||
createdAt: at.createdAt,
|
||||
updatedAt: at.updatedAt,
|
||||
...(at.turnId !== undefined ? { turnId: at.turnId } : {}),
|
||||
})),
|
||||
proposals: a.proposals.map((p) => ({
|
||||
},
|
||||
at,
|
||||
),
|
||||
),
|
||||
proposals: a.proposals.map((p) =>
|
||||
withUnknowns(
|
||||
{
|
||||
id: p.id,
|
||||
anchorId: p.anchorId,
|
||||
replacement: p.replacement,
|
||||
@@ -163,7 +245,12 @@ export function serializeArtifact(a: Artifact): string {
|
||||
createdAt: p.createdAt,
|
||||
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
|
||||
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
|
||||
})),
|
||||
};
|
||||
},
|
||||
p,
|
||||
),
|
||||
),
|
||||
},
|
||||
a,
|
||||
);
|
||||
return JSON.stringify(canonical, null, 2) + "\n";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+8
-1
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { emptyArtifact, serializeArtifact, type Artifact } from "./model";
|
||||
import { SCHEMA_VERSION, emptyArtifact, isNewerMajor, serializeArtifact, type Artifact } from "./model";
|
||||
|
||||
export class CoauthorStore {
|
||||
/**
|
||||
@@ -67,6 +67,13 @@ export class CoauthorStore {
|
||||
*/
|
||||
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
|
||||
const artifact = this.load(docPath) ?? emptyArtifact(docPath);
|
||||
if (isNewerMajor(artifact)) {
|
||||
// INV-16 backstop: the controllers gate first (VersionGuard); this throw
|
||||
// guarantees no missed write path can ever destroy a newer rung's data.
|
||||
throw new Error(
|
||||
`refusing to write ${docPath}: sidecar schemaVersion ${artifact.schemaVersion} > supported ${SCHEMA_VERSION} (INV-16)`,
|
||||
);
|
||||
}
|
||||
mutate(artifact);
|
||||
const referenced = new Set<string>([
|
||||
...artifact.threads.map((t) => t.anchorId),
|
||||
|
||||
+13
-2
@@ -11,7 +11,9 @@ import * as vscode from "vscode";
|
||||
import { CoauthorStore } from "./store";
|
||||
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 {
|
||||
@@ -38,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) => {
|
||||
@@ -72,7 +78,8 @@ export class ThreadController implements vscode.Disposable {
|
||||
|
||||
private currentAuthor(): Provenance {
|
||||
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
|
||||
return { kind: "human", id };
|
||||
const email = gitUserEmail(this.rootDir);
|
||||
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
|
||||
}
|
||||
|
||||
private persist(state: DocState): void {
|
||||
@@ -106,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 = {
|
||||
@@ -125,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);
|
||||
@@ -134,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;
|
||||
@@ -196,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { emptyArtifact, serializeArtifact, type Artifact } from "../src/model";
|
||||
import { validateSidecar } from "./helpers/validateSidecar";
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, "../scripts/crossrung-reply.mjs");
|
||||
let dir: string;
|
||||
let sidecar: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-crossrung-"));
|
||||
sidecar = path.join(dir, "sample.md.json");
|
||||
const a = emptyArtifact("docs/sample.md");
|
||||
a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0 } };
|
||||
a.threads.push({
|
||||
id: "t_1",
|
||||
anchorId: "a_1",
|
||||
status: "open",
|
||||
messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }],
|
||||
});
|
||||
// INV-15 must hold through a FOREIGN writer too
|
||||
(a as unknown as Record<string, unknown>).futureSection = [{ id: "f_1" }];
|
||||
fs.writeFileSync(sidecar, serializeArtifact(a), "utf8");
|
||||
});
|
||||
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
function run(...args: string[]): string {
|
||||
return execFileSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
describe("crossrung-reply stand-in (F5 SLICE-4, PUC-2)", () => {
|
||||
it("appends a conforming reply, preserves unknowns, byte-identical to the reference serializer", () => {
|
||||
run(sidecar, "t_1", "Reply from the forge", "--author-id", "gitea", "--author-email", "forge@example.org");
|
||||
const raw = fs.readFileSync(sidecar, "utf8");
|
||||
const parsed = JSON.parse(raw) as Artifact;
|
||||
expect(parsed.threads[0].messages).toHaveLength(2);
|
||||
const reply = parsed.threads[0].messages[1];
|
||||
expect(reply.body).toBe("Reply from the forge");
|
||||
expect(reply.author).toEqual({ kind: "human", id: "gitea", email: "forge@example.org" });
|
||||
expect(reply.id).toMatch(/^m_/);
|
||||
expect((parsed as unknown as Record<string, unknown>).futureSection).toEqual([{ id: "f_1" }]);
|
||||
expect(validateSidecar(parsed).valid).toBe(true);
|
||||
// the stand-in's independent serialization is byte-identical to the
|
||||
// editor's (INV-2 across writers) — the contract-drift tripwire.
|
||||
expect(serializeArtifact(parsed)).toBe(raw);
|
||||
});
|
||||
|
||||
it("refuses an unknown thread id and leaves the file untouched", () => {
|
||||
const before = fs.readFileSync(sidecar, "utf8");
|
||||
expect(() => run(sidecar, "t_nope", "x")).toThrow();
|
||||
expect(fs.readFileSync(sidecar, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("refuses an invalid sidecar (validates BEFORE writing)", () => {
|
||||
fs.writeFileSync(sidecar, '{"schemaVersion":1}\n', "utf8");
|
||||
expect(() => run(sidecar, "t_1", "x")).toThrow();
|
||||
});
|
||||
|
||||
it("refuses a newer-major sidecar (INV-16 holds for foreign writers too)", () => {
|
||||
const v2 = JSON.parse(fs.readFileSync(sidecar, "utf8"));
|
||||
v2.schemaVersion = 2;
|
||||
fs.writeFileSync(sidecar, JSON.stringify(v2, null, 2) + "\n", "utf8");
|
||||
const before = fs.readFileSync(sidecar, "utf8");
|
||||
expect(() => run(sidecar, "t_1", "x")).toThrow();
|
||||
expect(fs.readFileSync(sidecar, "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Crossrung doc
|
||||
|
||||
The portable record travels with ordinary git.
|
||||
|
||||
Nothing else here.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Future doc
|
||||
|
||||
Anchored text from the future.
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as assert from "assert";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import type { Artifact } from "../../../src/model";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const PROJECT_ROOT = path.resolve(__dirname, "../../../..");
|
||||
const STANDIN = path.join(PROJECT_ROOT, "scripts", "crossrung-reply.mjs");
|
||||
|
||||
function sidecarPath(docRel: string): string {
|
||||
return path.join(WS, ".threads", `${docRel}.json`);
|
||||
}
|
||||
async function open(docRel: string): Promise<vscode.TextDocument> {
|
||||
const uri = vscode.Uri.file(path.join(WS, docRel));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.versionGuard, "extension exports versionGuard (F5)");
|
||||
return api;
|
||||
}
|
||||
// The extension host is Electron: run the stand-in with the same binary in
|
||||
// plain-node mode — a forge's write is just a local process to git (fork e).
|
||||
function runStandin(...args: string[]): void {
|
||||
execFileSync(process.execPath, [STANDIN, ...args], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
|
||||
});
|
||||
}
|
||||
function commentsOf(api: CowritingApi, docRel: string, threadId: string): number {
|
||||
const docs = (
|
||||
api.threadController as unknown as {
|
||||
docs: Map<string, { vsThreads: Map<string, vscode.CommentThread> }>;
|
||||
}
|
||||
).docs;
|
||||
const vsThread = docs.get(docRel)?.vsThreads.get(threadId);
|
||||
return vsThread ? vsThread.comments.length : -1;
|
||||
}
|
||||
async function until(cond: () => boolean, ms = 5000): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
while (!cond()) {
|
||||
if (Date.now() - t0 > ms) throw new Error("timed out waiting for condition");
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
|
||||
suite("F5 cross-rung round-trip (host E2E)", () => {
|
||||
test("stand-in reply lands in the editor's thread via external change (PUC-2)", async () => {
|
||||
const DOC = "docs/crossrung.md";
|
||||
const doc = await open(DOC);
|
||||
const api = await getApi();
|
||||
const target = "portable record";
|
||||
const start = doc.getText().indexOf(target);
|
||||
const editor = vscode.window.activeTextEditor!;
|
||||
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + target.length));
|
||||
const threadId = await api.threadController.createThreadOnSelection("Editor note");
|
||||
assert.ok(threadId, "thread created in the editor");
|
||||
|
||||
// the Gitea-rung stand-in appends a reply OUT OF EDITOR (spec fork e)
|
||||
runStandin(sidecarPath(DOC), threadId!, "Reply from the forge", "--author-id", "gitea", "--author-email", "forge@example.org");
|
||||
|
||||
// watcher → external change → re-render with the new message (the F2 path)
|
||||
await until(() => commentsOf(api, DOC, threadId!) === 2);
|
||||
const rendered = api.threadController.getRendered(DOC);
|
||||
const mine = rendered.find((r) => r.id === threadId)!;
|
||||
assert.strictEqual(mine.orphaned, false, "anchor still resolves after the foreign write");
|
||||
const anchorLine = doc.positionAt(doc.getText().indexOf(target)).line;
|
||||
assert.strictEqual(mine.range.startLine, anchorLine, "rendered at the re-resolved anchor");
|
||||
|
||||
const art = JSON.parse(fs.readFileSync(sidecarPath(DOC), "utf8")) as Artifact;
|
||||
const msgs = art.threads.find((t) => t.id === threadId)!.messages;
|
||||
assert.strictEqual(msgs.length, 2);
|
||||
assert.deepStrictEqual(msgs[1].author, { kind: "human", id: "gitea", email: "forge@example.org" });
|
||||
});
|
||||
|
||||
test("newer-major sidecar: warning + read-only, file bytes never change (PUC-4)", async () => {
|
||||
const DOC = "docs/v2.md";
|
||||
const scPath = sidecarPath(DOC);
|
||||
fs.mkdirSync(path.dirname(scPath), { recursive: true });
|
||||
// a v2 sidecar: valid v1 sections plus future content this build can't know
|
||||
const v2 = {
|
||||
schemaVersion: 2,
|
||||
document: { path: DOC },
|
||||
anchors: {
|
||||
a_1: { fingerprint: { text: "Anchored text", before: "", after: " from the future.", lineHint: 2 } },
|
||||
},
|
||||
threads: [
|
||||
{
|
||||
id: "t_1",
|
||||
anchorId: "a_1",
|
||||
status: "open",
|
||||
messages: [{ id: "m_1", author: { kind: "human", id: "future" }, body: "from v2", createdAt: "2026-06-10T00:00:00.000Z" }],
|
||||
},
|
||||
],
|
||||
attributions: [],
|
||||
proposals: [],
|
||||
v2OnlySection: { shiny: true },
|
||||
};
|
||||
fs.writeFileSync(scPath, JSON.stringify(v2, null, 2) + "\n", "utf8");
|
||||
const before = fs.readFileSync(scPath, "utf8");
|
||||
|
||||
const doc = await open(DOC);
|
||||
const api = await getApi();
|
||||
api.threadController.renderAll(doc); // render best-effort: the v1 subset shows
|
||||
const rendered = api.threadController.getRendered(DOC);
|
||||
assert.strictEqual(rendered.length, 1, "renders what it understands");
|
||||
|
||||
// write gestures are refused + warned (PUC-4)
|
||||
const target = "Anchored text";
|
||||
const start = doc.getText().indexOf(target);
|
||||
const editor = vscode.window.activeTextEditor!;
|
||||
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + target.length));
|
||||
const created = await api.threadController.createThreadOnSelection("should not land");
|
||||
assert.strictEqual(created, undefined, "createThread refused on newer major");
|
||||
assert.strictEqual(api.versionGuard.wasWarned(DOC), true, "user warned once (PUC-4)");
|
||||
|
||||
// an edit + save must NOT rewrite the sidecar (INV-16)
|
||||
await editor.edit((b) => b.insert(doc.positionAt(0), "edited "));
|
||||
await doc.save();
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
assert.strictEqual(fs.readFileSync(scPath, "utf8"), before, "sidecar bytes untouched (INV-16)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Schema-conformance helper (F5 SLICE-1, PUC-5): every serialized artifact in
|
||||
* the unit suite must validate against the published cross-rung schema —
|
||||
* contract drift fails CI (spec §7.4).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import Ajv from "ajv/dist/2020";
|
||||
import { serializeArtifact, type Artifact } from "../../src/model";
|
||||
|
||||
const schemaUrl = new URL("../../schemas/coauthoring-sidecar.schema.json", import.meta.url);
|
||||
const schema = JSON.parse(readFileSync(schemaUrl, "utf8"));
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
export function validateSidecar(data: unknown): { valid: boolean; errors: string[] } {
|
||||
const valid = validate(data) as boolean;
|
||||
const errors = (validate.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message}`);
|
||||
return { valid, errors };
|
||||
}
|
||||
|
||||
export function expectValidSidecar(artifact: Artifact): void {
|
||||
const { valid, errors } = validateSidecar(JSON.parse(serializeArtifact(artifact)));
|
||||
if (!valid) throw new Error(`sidecar failed schema validation:\n${errors.join("\n")}`);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { gitUserEmail } from "../src/identity";
|
||||
|
||||
let repo: string;
|
||||
|
||||
beforeAll(() => {
|
||||
repo = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-identity-"));
|
||||
execFileSync("git", ["init"], { cwd: repo, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "rung@example.org"], { cwd: repo });
|
||||
});
|
||||
afterAll(() => fs.rmSync(repo, { recursive: true, force: true }));
|
||||
|
||||
describe("gitUserEmail (F5 SLICE-2)", () => {
|
||||
it("reads the workspace git config user.email", () => {
|
||||
expect(gitUserEmail(repo)).toBe("rung@example.org");
|
||||
});
|
||||
|
||||
it("fails open (undefined) when git cannot run there", () => {
|
||||
expect(gitUserEmail("/nonexistent-dir-xyz")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { emptyArtifact, type Artifact } from "../src/model";
|
||||
import { mergeArtifacts } from "../src/mergeArtifacts";
|
||||
import { expectValidSidecar } from "./helpers/validateSidecar";
|
||||
|
||||
const FP = { text: "alpha", before: "", after: "", lineHint: 0 };
|
||||
|
||||
function base(): Artifact {
|
||||
const a = emptyArtifact("docs/x.md");
|
||||
a.anchors["a_1"] = { fingerprint: FP };
|
||||
a.threads.push({
|
||||
id: "t_1",
|
||||
anchorId: "a_1",
|
||||
status: "open",
|
||||
messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }],
|
||||
});
|
||||
return a;
|
||||
}
|
||||
const clone = (a: Artifact): Artifact => JSON.parse(JSON.stringify(a));
|
||||
|
||||
describe("mergeArtifacts (F5 SLICE-3, INV-17)", () => {
|
||||
it("reply-append vs disjoint edit unions cleanly, no conflicts (the forge case)", () => {
|
||||
const ours = base();
|
||||
ours.attributions.push({
|
||||
id: "at_1",
|
||||
anchorId: "a_1",
|
||||
author: { kind: "human", id: "ben" },
|
||||
createdAt: "2026-06-10T01:00:00.000Z",
|
||||
updatedAt: "2026-06-10T01:00:00.000Z",
|
||||
});
|
||||
const theirs = base();
|
||||
theirs.threads[0].messages.push({
|
||||
id: "m_2",
|
||||
author: { kind: "human", id: "forge", email: "forge@example.org" },
|
||||
body: "reply",
|
||||
createdAt: "2026-06-10T02:00:00.000Z",
|
||||
});
|
||||
const { merged, conflicts } = mergeArtifacts(ours, theirs);
|
||||
expect(conflicts).toEqual([]);
|
||||
expect(merged.threads[0].messages.map((m) => m.id)).toEqual(["m_1", "m_2"]);
|
||||
expect(merged.attributions.map((a) => a.id)).toEqual(["at_1"]);
|
||||
expectValidSidecar(merged);
|
||||
});
|
||||
|
||||
it("both sides append to the same thread: messages interleave by createdAt, no conflicts", () => {
|
||||
const ours = base();
|
||||
ours.threads[0].messages.push({ id: "m_b", author: { kind: "human", id: "ben" }, body: "B", createdAt: "2026-06-10T03:00:00.000Z" });
|
||||
const theirs = base();
|
||||
theirs.threads[0].messages.push({ id: "m_a", author: { kind: "human", id: "forge" }, body: "A", createdAt: "2026-06-10T02:00:00.000Z" });
|
||||
const { merged, conflicts } = mergeArtifacts(ours, theirs);
|
||||
expect(conflicts).toEqual([]);
|
||||
expect(merged.threads[0].messages.map((m) => m.id)).toEqual(["m_1", "m_a", "m_b"]);
|
||||
});
|
||||
|
||||
it("same attribution id divergent: newer updatedAt wins and the id is reported", () => {
|
||||
const rec = {
|
||||
id: "at_1",
|
||||
anchorId: "a_1",
|
||||
author: { kind: "human", id: "ben" } as const,
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
};
|
||||
const ours = base();
|
||||
ours.attributions.push({ ...rec, updatedAt: "2026-06-10T05:00:00.000Z" });
|
||||
const theirs = base();
|
||||
theirs.attributions.push({ ...rec, updatedAt: "2026-06-10T04:00:00.000Z", turnId: "turn-x" });
|
||||
const { merged, conflicts } = mergeArtifacts(ours, theirs);
|
||||
expect(conflicts).toEqual(["at_1"]);
|
||||
expect(merged.attributions[0].updatedAt).toBe("2026-06-10T05:00:00.000Z");
|
||||
expect(merged.attributions[0].turnId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("divergent same-id proposals: deterministic tie-break, id reported, symmetric result", () => {
|
||||
const p = {
|
||||
id: "p_1",
|
||||
anchorId: "a_1",
|
||||
replacement: "AAA",
|
||||
author: { kind: "human", id: "ben" } as const,
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
};
|
||||
const ours = base();
|
||||
ours.proposals.push({ ...p });
|
||||
const theirs = base();
|
||||
theirs.proposals.push({ ...p, replacement: "ZZZ" });
|
||||
const r1 = mergeArtifacts(ours, theirs);
|
||||
const r2 = mergeArtifacts(theirs, ours);
|
||||
expect(r1.conflicts).toEqual(["p_1"]);
|
||||
expect(r1.merged.proposals[0].replacement).toBe("ZZZ");
|
||||
expect(r2.merged.proposals[0].replacement).toBe("ZZZ");
|
||||
});
|
||||
|
||||
it("thread status divergence resolves deterministically and reports the thread id", () => {
|
||||
const ours = base();
|
||||
const theirs = clone(ours);
|
||||
theirs.threads[0].status = "resolved";
|
||||
const { merged, conflicts } = mergeArtifacts(ours, theirs);
|
||||
expect(merged.threads[0].status).toBe("resolved");
|
||||
expect(conflicts).toEqual(["t_1"]);
|
||||
});
|
||||
|
||||
it("divergent same-id messages tie-break symmetrically and report the message id", () => {
|
||||
const ours = base();
|
||||
const theirs = clone(ours);
|
||||
theirs.threads[0].messages[0].body = "hi (edited)";
|
||||
const r1 = mergeArtifacts(ours, theirs);
|
||||
const r2 = mergeArtifacts(theirs, ours);
|
||||
expect(r1.conflicts).toEqual(["m_1"]);
|
||||
expect(r2.conflicts).toEqual(["m_1"]);
|
||||
// deterministic + symmetric: both directions pick the same winner
|
||||
expect(r1.merged.threads[0].messages[0].body).toBe(r2.merged.threads[0].messages[0].body);
|
||||
});
|
||||
|
||||
it("anchors union by key; divergent same key reported", () => {
|
||||
const ours = base();
|
||||
const theirs = base();
|
||||
theirs.anchors["a_2"] = { fingerprint: { ...FP, text: "beta" } };
|
||||
const clean = mergeArtifacts(ours, theirs);
|
||||
expect(Object.keys(clean.merged.anchors).sort()).toEqual(["a_1", "a_2"]);
|
||||
expect(clean.conflicts).toEqual([]);
|
||||
const theirs2 = clone(ours);
|
||||
theirs2.anchors["a_1"] = { fingerprint: { ...FP, lineHint: 9 } };
|
||||
const dirty = mergeArtifacts(ours, theirs2);
|
||||
expect(dirty.conflicts).toEqual(["a_1"]);
|
||||
});
|
||||
|
||||
it("unknown fields ride with the winning record and root unknowns union", () => {
|
||||
const ours = base() as Artifact & Record<string, unknown>;
|
||||
ours.futureSection = [1];
|
||||
const theirs = base() as Artifact & Record<string, unknown>;
|
||||
theirs.otherFuture = "x";
|
||||
const { merged, conflicts } = mergeArtifacts(ours, theirs);
|
||||
expect((merged as unknown as Record<string, unknown>).futureSection).toEqual([1]);
|
||||
expect((merged as unknown as Record<string, unknown>).otherFuture).toBe("x");
|
||||
expect(conflicts).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on differing majors (INV-16)", () => {
|
||||
const ours = base();
|
||||
const theirs = { ...base(), schemaVersion: 2 };
|
||||
expect(() => mergeArtifacts(ours, theirs)).toThrow(/schemaVersion/);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SCHEMA_VERSION,
|
||||
emptyArtifact,
|
||||
isNewerMajor,
|
||||
serializeArtifact,
|
||||
type Artifact,
|
||||
} from "../src/model";
|
||||
@@ -57,6 +58,127 @@ describe("serializeArtifact", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-rung identity (F5 SLICE-2)", () => {
|
||||
it("round-trips email on human provenance, omitted when absent", () => {
|
||||
const a = emptyArtifact("docs/x.md");
|
||||
a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0 } };
|
||||
a.threads.push({
|
||||
id: "t_1",
|
||||
anchorId: "a_1",
|
||||
status: "open",
|
||||
messages: [
|
||||
{ id: "m_1", author: { kind: "human", id: "ben", email: "ben@wiggleverse.org" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" },
|
||||
{ id: "m_2", author: { kind: "human", id: "anon" }, body: "yo", createdAt: "2026-06-10T00:00:01.000Z" },
|
||||
],
|
||||
});
|
||||
const out = serializeArtifact(a);
|
||||
const parsed = JSON.parse(out) as Artifact;
|
||||
expect(parsed.threads[0].messages[0].author).toEqual({ kind: "human", id: "ben", email: "ben@wiggleverse.org" });
|
||||
expect("email" in parsed.threads[0].messages[1].author).toBe(false);
|
||||
expect(serializeArtifact(parsed)).toBe(out);
|
||||
});
|
||||
|
||||
it("round-trips agent.onBehalfOf and omits it when absent", () => {
|
||||
const a = emptyArtifact("docs/x.md");
|
||||
a.attributions.push({
|
||||
id: "at_1",
|
||||
anchorId: "a_1",
|
||||
author: {
|
||||
kind: "agent",
|
||||
id: "claude",
|
||||
agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1", onBehalfOf: { id: "benstull", email: "ben@wiggleverse.org" } },
|
||||
},
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
});
|
||||
const out = serializeArtifact(a);
|
||||
const parsed = JSON.parse(out) as Artifact;
|
||||
const author = parsed.attributions[0].author;
|
||||
expect(author.kind).toBe("agent");
|
||||
if (author.kind === "agent") {
|
||||
expect(author.agent.onBehalfOf).toEqual({ id: "benstull", email: "ben@wiggleverse.org" });
|
||||
}
|
||||
expect(out.includes("onBehalfOf")).toBe(true);
|
||||
const a2 = emptyArtifact("docs/x.md");
|
||||
expect(serializeArtifact(a2).includes("onBehalfOf")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNewerMajor (F5 SLICE-2, INV-16)", () => {
|
||||
it("is false for v1 and true for a newer major", () => {
|
||||
expect(isNewerMajor(emptyArtifact("d.md"))).toBe(false);
|
||||
expect(isNewerMajor({ schemaVersion: 2 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => {
|
||||
it("a rewrite preserves unknown fields at every level, after known keys, sorted", () => {
|
||||
const foreign = {
|
||||
schemaVersion: 1,
|
||||
document: { path: "docs/x.md", zFuture: "keep-me" },
|
||||
anchors: {
|
||||
a_1: { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0, confidence: 0.9 }, label: "intro" },
|
||||
},
|
||||
threads: [
|
||||
{
|
||||
id: "t_1",
|
||||
anchorId: "a_1",
|
||||
status: "open",
|
||||
messages: [
|
||||
{
|
||||
id: "m_1",
|
||||
author: { kind: "human", id: "ben", forgeLogin: "bstull" },
|
||||
body: "hi",
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
reactions: ["+1"],
|
||||
},
|
||||
],
|
||||
locked: false,
|
||||
},
|
||||
],
|
||||
attributions: [
|
||||
{
|
||||
id: "at_1",
|
||||
anchorId: "a_1",
|
||||
author: { kind: "human", id: "ben" },
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
reviewState: "approved",
|
||||
},
|
||||
],
|
||||
proposals: [
|
||||
{
|
||||
id: "p_1",
|
||||
anchorId: "a_1",
|
||||
replacement: "ALPHA",
|
||||
author: { kind: "human", id: "ben" },
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
priority: 2,
|
||||
},
|
||||
],
|
||||
futureSection: [{ id: "f_1" }],
|
||||
aaaEarly: true,
|
||||
};
|
||||
const out = serializeArtifact(foreign as unknown as Artifact);
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed.futureSection).toEqual([{ id: "f_1" }]);
|
||||
expect(parsed.aaaEarly).toBe(true);
|
||||
expect(parsed.document.zFuture).toBe("keep-me");
|
||||
expect(parsed.anchors.a_1.label).toBe("intro");
|
||||
expect(parsed.anchors.a_1.fingerprint.confidence).toBe(0.9);
|
||||
expect(parsed.threads[0].locked).toBe(false);
|
||||
expect(parsed.threads[0].messages[0].reactions).toEqual(["+1"]);
|
||||
expect(parsed.threads[0].messages[0].author.forgeLogin).toBe("bstull");
|
||||
expect(parsed.attributions[0].reviewState).toBe("approved");
|
||||
expect(parsed.proposals[0].priority).toBe(2);
|
||||
// unknown ROOT keys serialize AFTER known keys, sorted: aaaEarly then futureSection, both after proposals
|
||||
expect(out.indexOf('"proposals"')).toBeLessThan(out.indexOf('"aaaEarly"'));
|
||||
expect(out.indexOf('"aaaEarly"')).toBeLessThan(out.indexOf('"futureSection"'));
|
||||
// byte-stable on re-serialize (INV-2 holds with unknowns present)
|
||||
expect(serializeArtifact(parsed as Artifact)).toBe(out);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attributions[] (F3 SLICE-1)", () => {
|
||||
it("round-trips an attribution record through serialize → parse", () => {
|
||||
const a = emptyArtifact("docs/x.md");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { emptyArtifact, serializeArtifact, type Artifact } from "../src/model";
|
||||
import { expectValidSidecar, validateSidecar } from "./helpers/validateSidecar";
|
||||
|
||||
function fullArtifact(): Artifact {
|
||||
const a = emptyArtifact("docs/x.md");
|
||||
a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: " beta", lineHint: 0 } };
|
||||
a.threads.push({
|
||||
id: "t_1",
|
||||
anchorId: "a_1",
|
||||
status: "open",
|
||||
messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }],
|
||||
});
|
||||
a.attributions.push({
|
||||
id: "at_1",
|
||||
anchorId: "a_1",
|
||||
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } },
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
turnId: "turn-1",
|
||||
});
|
||||
a.proposals.push({
|
||||
id: "p_1",
|
||||
anchorId: "a_1",
|
||||
replacement: "ALPHA",
|
||||
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } },
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
instruction: "shout",
|
||||
});
|
||||
return a;
|
||||
}
|
||||
|
||||
describe("coauthoring-sidecar JSON Schema (F5 SLICE-1, PUC-5)", () => {
|
||||
it("validates the empty artifact and a full artifact", () => {
|
||||
expectValidSidecar(emptyArtifact("docs/x.md"));
|
||||
expectValidSidecar(fullArtifact());
|
||||
});
|
||||
|
||||
it("rejects a structurally broken sidecar with error paths", () => {
|
||||
const broken = JSON.parse(serializeArtifact(fullArtifact()));
|
||||
broken.threads[0].status = "closed"; // not in the enum
|
||||
delete broken.schemaVersion;
|
||||
const { valid, errors } = validateSidecar(broken);
|
||||
expect(valid).toBe(false);
|
||||
expect(errors.some((e) => e.includes("schemaVersion"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts unknown fields everywhere (forward compat, INV-16)", () => {
|
||||
const data = JSON.parse(serializeArtifact(fullArtifact()));
|
||||
data.futureSection = [{ id: "f_1" }];
|
||||
data.threads[0].futureField = true;
|
||||
data.threads[0].messages[0].reactions = ["+1"];
|
||||
const { valid } = validateSidecar(data);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -107,3 +107,20 @@ describe("CoauthorStore", () => {
|
||||
expect(store.consumeSelfWrite(p)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("newer-major write refusal (F5 SLICE-2, INV-16 backstop)", () => {
|
||||
it("update REFUSES to write a sidecar from a newer major, leaving bytes untouched", () => {
|
||||
const store = new CoauthorStore(root);
|
||||
const p = store.sidecarPath("docs/v2.md");
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
const v2 = { ...emptyArtifact("docs/v2.md"), schemaVersion: 2 };
|
||||
fs.writeFileSync(p, serializeArtifact(v2), "utf8");
|
||||
const before = fs.readFileSync(p, "utf8");
|
||||
expect(() =>
|
||||
store.update("docs/v2.md", (a) => {
|
||||
a.threads = [];
|
||||
}),
|
||||
).toThrow(/INV-16/);
|
||||
expect(fs.readFileSync(p, "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user