feat: crossrung-reply stand-in writer — validate, append, stable-serialize (F5 SLICE-4, #14)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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}`);
|
||||
Reference in New Issue
Block a user