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

274 lines
8.4 KiB
TypeScript

/**
* Versioned coauthoring artifact schema (spec §6.3) — the shared git-native
* envelope under every coauthoring capability. `anchors` and the `Provenance`
* author field are SHARED primitives (INV-4): F3 (attributions) and F4
* (proposals) add arrays that reference the same shapes without a format break.
*
* vscode-free and pure, so it is unit-testable in Node (mirrors src/cline.ts).
*/
import { randomUUID } from "node:crypto";
export const SCHEMA_VERSION = 1 as const;
/** Anchored text + bounded context + a line tie-breaker (spec §6.3). */
export interface Fingerprint {
text: string;
/** <= ~3 lines / 120 chars leading context. */
before: string;
/** <= ~3 lines / 120 chars trailing context. */
after: string;
/** 0-based line of the anchor start; a tie-breaker, NOT truth (INV-3). */
lineHint: number;
}
export interface Anchor {
fingerprint: Fingerprint;
}
/**
* 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; 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;
author: Provenance;
body: string;
/** ISO-8601. */
createdAt: string;
}
export type ThreadStatus = "open" | "resolved";
export interface Thread {
id: string;
anchorId: string;
status: ThreadStatus;
messages: Message[];
}
/** F3 (spec §6.3): an anchored, author-attributed span — state, not history. */
export interface AttributionRecord {
id: string;
/** shared anchors map — same Fingerprint primitive (INV-4). */
anchorId: string;
author: Provenance;
/** ISO-8601. */
createdAt: string;
/** ISO-8601; bumped when the span's extent/fingerprint changes. */
updatedAt: string;
/** groups all spans applied by one live turn. */
turnId?: string;
}
/** F4 (spec §6.3, INV-13): a PENDING machine edit — state, not history. */
export interface Proposal {
id: string;
/** shared anchors map; fingerprint.text IS the exact target text (INV-11). */
anchorId: string;
/** the full proposed text for the anchored range. */
replacement: string;
author: Provenance;
/** ISO-8601. */
createdAt: string;
/** groups N proposals born of one live turn. */
turnId?: string;
/** what the human asked for (review context). */
instruction?: string;
/**
* F12/#47 (INV-39/40): the review-decision unit this proposal represents.
* `"block"` ⇒ the anchor spans a whole document block and accept reconciles
* attribution per WORD inside it (INV-40); `"single"` (or absent, for
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
*/
granularity?: "block" | "single";
/**
* F12/#64 (INV-48): the pre-apply text, captured when a proposal is
* optimistically applied to the buffer. `replacement` is now in the buffer and
* `fp.text` re-anchors to it, so `original` is the only record of what to revert
* to (revert-in-place) and what to show struck in the `<del>` half. Absent on a
* proposal created but not yet optimistically applied (or older sidecars).
*/
original?: string;
}
export interface Artifact {
schemaVersion: number;
/** repo-relative path; the sidecar key. */
document: { path: string };
/** SHARED primitive (INV-4). */
anchors: Record<string, Anchor>;
threads: Thread[];
/** F3 fills this (spec §6.3, INV-4): typed attribution records anchored via anchors[]. */
attributions: AttributionRecord[];
/** F4 (spec §6.3, INV-13): pending proposals on shared anchors[] + Provenance. */
proposals: Proposal[];
}
export function emptyArtifact(docPath: string): Artifact {
return {
schemaVersion: SCHEMA_VERSION,
document: { path: docPath },
anchors: {},
threads: [],
attributions: [],
proposals: [],
};
}
/**
* 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> {
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);
}
/**
* Pretty-printed JSON with STABLE key ordering (anchors sorted by id), so
* sidecar diffs stay minimal and merge-friendly (spec §6.3, INV-2). Always
* ends with a trailing newline.
*/
export function serializeArtifact(a: Artifact): string {
const canonical = withUnknowns(
{
schemaVersion: a.schemaVersion,
document: withUnknowns({ path: a.document.path }, a.document),
anchors: Object.fromEntries(
Object.keys(a.anchors)
.sort()
.map((k) => [
k,
withUnknowns(
{
fingerprint: withUnknowns(
{
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) =>
withUnknowns(
{
id: t.id,
anchorId: t.anchorId,
status: t.status,
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 } : {}),
},
at,
),
),
proposals: a.proposals.map((p) =>
withUnknowns(
{
id: p.id,
anchorId: p.anchorId,
replacement: p.replacement,
author: serializeProvenance(p.author),
createdAt: p.createdAt,
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
...(p.original !== undefined ? { original: p.original } : {}),
...(p.granularity !== undefined ? { granularity: p.granularity } : {}),
},
p,
),
),
},
a,
);
return JSON.stringify(canonical, null, 2) + "\n";
}