trantor 0.18.49 → 0.18.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/baton-pane.mjs +62 -9
- package/bin/baton.mjs +8 -2
- package/bin/connect.mjs +44 -4
- package/bin/crew/open.mjs +54 -5
- package/bin/crew-runner.mjs +98 -12
- package/bin/doctor.mjs +61 -1
- package/bin/drill-report.mjs +80 -0
- package/bin/drill-report.test.mjs +146 -0
- package/bin/drill-seams.mjs +157 -0
- package/bin/drill-surface.mjs +157 -54
- package/bin/duty.mjs +68 -1
- package/bin/write-handoff.mjs +7 -1
- package/deploy/setup.sh +38 -0
- package/hooks/lib/handoff.mjs +158 -2
- package/hooks/sessionstart.mjs +10 -0
- package/lib/duty-nudges.mjs +48 -10
- package/lib/state/apply.mjs +170 -0
- package/lib/state/derive.mjs +229 -0
- package/lib/state/gate.mjs +355 -0
- package/lib/state/migrate.mjs +126 -0
- package/lib/state/promote.mjs +122 -0
- package/lib/state/schema.mjs +216 -0
- package/lib/state/store.mjs +442 -0
- package/lib/state/validate.mjs +231 -0
- package/package.json +1 -1
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// Trantor State P1 — the store (TDD §4.4). Where a WorkingState lives, how it is written without
|
|
2
|
+
// ever being torn, how two writers are kept off each other without a lock file, and what a turn
|
|
3
|
+
// that was killed mid-flight leaves behind.
|
|
4
|
+
//
|
|
5
|
+
// Three things this file deliberately does NOT do, each of them a temptation:
|
|
6
|
+
//
|
|
7
|
+
// 1. It does not replay the journal to recover. The design admits exactly ONE apply point per
|
|
8
|
+
// turn, after the CLI has exited, so a killed turn has never half-applied a patch: the
|
|
9
|
+
// on-disk state is the last good state, whole. What the dead turn lost is its OBSERVATIONS,
|
|
10
|
+
// so recovery reconstructs from ground truth (git), never from `<sidecar>.ops.jsonl`. The
|
|
11
|
+
// journal is forensics and test replay, and saying so out loud is the point — recovery from a
|
|
12
|
+
// journal would need the journal write and the state write to be one atomic act, which they
|
|
13
|
+
// are not, and we are not claiming a durability we do not have.
|
|
14
|
+
// 2. It does not take a lock. Concurrency is a compare-and-swap on `rev`. A lock file in
|
|
15
|
+
// ~/.agent-bus outlives the process holding it, and this project has already paid for that.
|
|
16
|
+
// 3. It does not retry a STALE commit. Curing STALE is the driver's job (re-read, re-run
|
|
17
|
+
// applyTurn on the fresh state — safe because ops are id-addressed), and a store that retried
|
|
18
|
+
// quietly would be making a policy decision it cannot see the consequences of.
|
|
19
|
+
//
|
|
20
|
+
// The whole package is dark: nothing in the running product imports it yet.
|
|
21
|
+
import {
|
|
22
|
+
existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { busDir } from "../project.mjs";
|
|
29
|
+
import { CAPS, ERR, emptyState, stateError } from "./schema.mjs";
|
|
30
|
+
import { migrate } from "./migrate.mjs";
|
|
31
|
+
|
|
32
|
+
/** The CAS-conflict outcome. The one code this file owns; no branch of validate.mjs produces it. */
|
|
33
|
+
export const STALE = "STALE";
|
|
34
|
+
|
|
35
|
+
/** Journal ring size — accepted patches kept for forensics and test replay (TDD §4.4). */
|
|
36
|
+
export const JOURNAL_LINES = 200;
|
|
37
|
+
|
|
38
|
+
/** A sidecar for a card in a terminal status becomes collectable after this. */
|
|
39
|
+
export const GC_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sanitise one path component. Session ids carry `:`, so the seat is folded exactly as
|
|
43
|
+
* hooks/ask-sidecar.mjs folds its own: everything outside [A-Za-z0-9._-] becomes `_`, and the
|
|
44
|
+
* three names that are not names — "", "." and ".." — are rejected rather than folded, because
|
|
45
|
+
* each of them addresses a directory rather than a file in it.
|
|
46
|
+
* @returns {string|null} the safe component, or null when there is no safe reading of the input
|
|
47
|
+
*/
|
|
48
|
+
export function sanitizeComponent(raw) {
|
|
49
|
+
const s = String(raw ?? "").trim().replace(/[^A-Za-z0-9._-]/g, "_");
|
|
50
|
+
if (!s || s === "." || s === "..") return null;
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `busDir()/state/<project>` — alongside asks/, handoffs/, claims/. */
|
|
55
|
+
export function stateDir(project) {
|
|
56
|
+
const p = sanitizeComponent(project);
|
|
57
|
+
return p ? join(busDir(), "state", p) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The sidecar path, per SEAT-CARD rather than per seat: a seat that switches cards must not
|
|
62
|
+
* inherit the previous card's in-flight list, and a card handed to another seat starts clean.
|
|
63
|
+
* @returns {string|null} null when the seat, card or project has no safe path form
|
|
64
|
+
*/
|
|
65
|
+
export function statePath(seat, card, project) {
|
|
66
|
+
const dir = stateDir(project);
|
|
67
|
+
const s = sanitizeComponent(seat);
|
|
68
|
+
if (!dir || !s || !Number.isInteger(card) || card < 0) return null;
|
|
69
|
+
return join(dir, `${s}--${card}.json`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The ops journal that sits beside a sidecar. Forensics and test replay ONLY (TDD §4.4). */
|
|
73
|
+
export function opsPath(seat, card, project) {
|
|
74
|
+
const p = statePath(seat, card, project);
|
|
75
|
+
return p ? `${p.slice(0, -".json".length)}.ops.jsonl` : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The cut marker bin/crew-runner.mjs writes when the shell's own time box kills a turn. The runner
|
|
80
|
+
* builds it from homedir() directly, so a test (or any run with AGENT_BUS_DIR set) has to look in
|
|
81
|
+
* both places or it would be testing a path production never writes.
|
|
82
|
+
*/
|
|
83
|
+
export function turncutPaths(agent, project) {
|
|
84
|
+
const a = sanitizeComponent(agent), p = sanitizeComponent(project);
|
|
85
|
+
if (!a || !p) return [];
|
|
86
|
+
const name = `turncut-${a}-${p}`;
|
|
87
|
+
return [...new Set([join(busDir(), name), join(homedir(), ".agent-bus", name)])];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Atomic by the house pattern: write a temp in the SAME directory, then rename over the target. */
|
|
91
|
+
function writeAtomic(path, body) {
|
|
92
|
+
const dir = path.slice(0, path.lastIndexOf("/"));
|
|
93
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
94
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(tmp, body, { mode: 0o600 });
|
|
97
|
+
renameSync(tmp, path);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
try { rmSync(tmp, { force: true }); } catch { /* the temp is already gone */ }
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function git(args, cwd) {
|
|
106
|
+
try {
|
|
107
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] });
|
|
108
|
+
} catch {
|
|
109
|
+
return null; // no git, no repo, or a path git refuses — recovery degrades, never throws
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every path git says has changed in the worktree, from `git status` in its NUL-separated form.
|
|
115
|
+
* `-z` is not a stylistic preference: the human-readable `--short` C-quotes any path with an
|
|
116
|
+
* unusual byte in it, and a recovery that mis-parses a quoted path credits `touched` to a file
|
|
117
|
+
* that does not exist. `-uall` because an untracked directory renders as one entry otherwise, and
|
|
118
|
+
* the caller needs files.
|
|
119
|
+
*
|
|
120
|
+
* A rename yields TWO paths — the new one and the original — and both are touched: the original
|
|
121
|
+
* because it is gone, the new one because it is new.
|
|
122
|
+
*/
|
|
123
|
+
export function gitTouched(cwd) {
|
|
124
|
+
const out = git(["status", "--porcelain", "-z", "-uall"], cwd);
|
|
125
|
+
if (out === null) return [];
|
|
126
|
+
const fields = out.split("\0").filter(f => f !== "");
|
|
127
|
+
const paths = [];
|
|
128
|
+
for (let i = 0; i < fields.length; i++) {
|
|
129
|
+
const entry = fields[i];
|
|
130
|
+
if (entry.length < 4) continue; // "XY path" — anything shorter is not an entry
|
|
131
|
+
const xy = entry.slice(0, 2);
|
|
132
|
+
paths.push(entry.slice(3));
|
|
133
|
+
// R/C entries are followed by their source path as its own NUL-terminated field.
|
|
134
|
+
if ((xy[0] === "R" || xy[0] === "C" || xy[1] === "R" || xy[1] === "C") && fields[i + 1] !== undefined) {
|
|
135
|
+
paths.push(fields[++i]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [...new Set(paths)];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Blob sha per path, by `git hash-object` — the same computation the gate used when it recorded
|
|
143
|
+
* `files[p].hash`, which is the whole point: a credit is compared against the tool that minted it,
|
|
144
|
+
* not against a lookalike. A path that no longer exists maps to null, and a null clears the credit
|
|
145
|
+
* exactly as a moved sha does.
|
|
146
|
+
* @returns {Map<string, string|null>}
|
|
147
|
+
*/
|
|
148
|
+
export function hashPaths(paths, cwd) {
|
|
149
|
+
const out = new Map();
|
|
150
|
+
const live = [];
|
|
151
|
+
for (const p of paths) {
|
|
152
|
+
if (!existsSync(join(cwd, p))) { out.set(p, null); continue; }
|
|
153
|
+
// `--stdin-paths` is newline-delimited, so a path containing a newline cannot ride the batch.
|
|
154
|
+
if (p.includes("\n")) {
|
|
155
|
+
const one = git(["hash-object", "--", p], cwd);
|
|
156
|
+
out.set(p, one ? one.trim() : null);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
live.push(p);
|
|
160
|
+
}
|
|
161
|
+
if (!live.length) return out;
|
|
162
|
+
let res = null;
|
|
163
|
+
try {
|
|
164
|
+
res = execFileSync("git", ["hash-object", "--stdin-paths"], {
|
|
165
|
+
cwd, encoding: "utf8", input: `${live.join("\n")}\n`, maxBuffer: 16 * 1024 * 1024,
|
|
166
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
res = null;
|
|
170
|
+
}
|
|
171
|
+
const shas = res === null ? [] : res.trim().split("\n").filter(Boolean);
|
|
172
|
+
// A short batch means git stopped early; the paths it never reached get null rather than a
|
|
173
|
+
// borrowed sha from the wrong file, which would keep a credit alive on unexamined bytes.
|
|
174
|
+
for (let i = 0; i < live.length; i++) out.set(live[i], shas[i] ?? null);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Append `line` to a notes tail, evicting whole lines from the front until it fits CAPS.NOTES.
|
|
180
|
+
* The eviction is line-wise on purpose: a mid-line elision is #6528, the bug that started all of
|
|
181
|
+
* this. apply.mjs and migrate.mjs each hold their own copy of this loop for their own tail; a
|
|
182
|
+
* shared helper in the pure core would be the right home for all three and is a P0 change, so it
|
|
183
|
+
* is asked for over the bus rather than made here under a second hat.
|
|
184
|
+
*/
|
|
185
|
+
function appendNote(notes, line) {
|
|
186
|
+
const all = [notes, line].filter(Boolean).join("\n").split("\n");
|
|
187
|
+
while (all.length > 1 && Buffer.byteLength(all.join("\n"), "utf8") > CAPS.NOTES) all.shift();
|
|
188
|
+
let outStr = all.join("\n");
|
|
189
|
+
while (outStr.length && Buffer.byteLength(outStr, "utf8") > CAPS.NOTES) outStr = outStr.slice(1);
|
|
190
|
+
return outStr;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Rebuild what a cut turn observed, from ground truth (TDD §4.4).
|
|
195
|
+
*
|
|
196
|
+
* Two halves, and the second is the one an earlier draft got wrong:
|
|
197
|
+
* - `touched` is re-derived from `git status`, because the dead turn's observations are what was
|
|
198
|
+
* lost, not its state.
|
|
199
|
+
* - every path still carrying `verified: true` is RE-HASHED and its credit CLEARED wherever the
|
|
200
|
+
* blob sha has moved off `files[p].hash` (or the file is gone). A cut turn is exactly the
|
|
201
|
+
* moment a file changes under a credit, so "leaves `verified` alone" was the stale-credit hole
|
|
202
|
+
* (R12) with a crash in front of it: the next `move → done` would pass route (b) on a green
|
|
203
|
+
* describing code that no longer exists, and because the evidence is stale-PRESENT rather than
|
|
204
|
+
* absent, NEEDS_GATE never fires and no memo hash can save it.
|
|
205
|
+
*
|
|
206
|
+
* Pure with respect to the sidecar: it reads git and returns a new state. Persisting it is
|
|
207
|
+
* readState's job, so the repair survives a turn that dies before its own commit.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} state
|
|
210
|
+
* @param {{ cwd?: string, turn?: number }} opts
|
|
211
|
+
* @returns {{ state: object, cleared: string[], touched: string[] }}
|
|
212
|
+
*/
|
|
213
|
+
export function recover(state, opts = {}) {
|
|
214
|
+
const cwd = opts.cwd || process.cwd();
|
|
215
|
+
const next = structuredClone(state);
|
|
216
|
+
const touched = gitTouched(cwd);
|
|
217
|
+
for (const p of touched) {
|
|
218
|
+
if (p.length > CAPS.PATH) continue; // a path the schema cannot hold is not a fact it can carry
|
|
219
|
+
const cur = next.files[p] || { touched: false, verified: false };
|
|
220
|
+
next.files[p] = { ...cur, touched: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const credited = Object.keys(next.files).filter(p => next.files[p].verified === true);
|
|
224
|
+
const shas = hashPaths(credited, cwd);
|
|
225
|
+
const cleared = [];
|
|
226
|
+
for (const p of credited) {
|
|
227
|
+
// `hash` is a string or absent by the schema (the state was decoded by migrate() on read), and
|
|
228
|
+
// `sha` is a 40-char blob sha or null, so an absent record can never accidentally compare equal.
|
|
229
|
+
const sha = shas.get(p) ?? null;
|
|
230
|
+
if (sha !== null && sha === next.files[p].hash) continue;
|
|
231
|
+
const kept = { ...next.files[p], verified: false };
|
|
232
|
+
delete kept.hash;
|
|
233
|
+
next.files[p] = kept;
|
|
234
|
+
cleared.push(p);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const turn = Number.isInteger(opts.turn) ? opts.turn : next.cursor.turn;
|
|
238
|
+
next.notes = appendNote(
|
|
239
|
+
next.notes,
|
|
240
|
+
`recovered: turn ${turn} was cut; touched paths re-derived from git; ${cleared.length} stale verifications cleared`,
|
|
241
|
+
);
|
|
242
|
+
return { state: next, cleared, touched };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Read + migrate + recover — the left edge of the §4.1 diagram.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} seat
|
|
249
|
+
* @param {number} card
|
|
250
|
+
* @param {{ project: string, cwd?: string, by?: string, agent?: string, recover?: boolean }} opts
|
|
251
|
+
* @returns {{ ok: true, state: object, path: string, created: boolean, migrated: boolean,
|
|
252
|
+
* recovered: null | { turn: number, cleared: string[], persisted: boolean } }
|
|
253
|
+
* | { ok: false, code: string, at: string, message: string }}
|
|
254
|
+
*/
|
|
255
|
+
export function readState(seat, card, opts = {}) {
|
|
256
|
+
const path = statePath(seat, card, opts.project);
|
|
257
|
+
if (!path) {
|
|
258
|
+
return { ok: false, code: ERR.SCHEMA, at: "path", message: `no safe sidecar path for seat ${JSON.stringify(seat)} card ${JSON.stringify(card)} project ${JSON.stringify(opts.project)}` };
|
|
259
|
+
}
|
|
260
|
+
const by = String(opts.by ?? seat ?? "");
|
|
261
|
+
if (!existsSync(path)) {
|
|
262
|
+
return { ok: true, state: emptyState(card, by), path, created: true, migrated: false, recovered: null };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let raw;
|
|
266
|
+
try { raw = readFileSync(path, "utf8"); }
|
|
267
|
+
catch (e) { return { ok: false, code: ERR.MIGRATE_FAILED, at: "read", message: `sidecar unreadable: ${e.message}` }; }
|
|
268
|
+
|
|
269
|
+
let parsed;
|
|
270
|
+
try { parsed = JSON.parse(raw); }
|
|
271
|
+
catch (e) {
|
|
272
|
+
preserve(path, raw, null);
|
|
273
|
+
return { ok: false, code: ERR.MIGRATE_FAILED, at: "parse", message: `sidecar is not JSON: ${e.message}` };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const m = migrate(parsed);
|
|
277
|
+
if (!m.ok) {
|
|
278
|
+
// The original is kept, untouched, beside itself — a half-upgraded sidecar is worse than a
|
|
279
|
+
// loud one, and the read stays loud on every retry until someone looks (TDD §4.3).
|
|
280
|
+
preserve(path, raw, parsed?.schema_version);
|
|
281
|
+
return m;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let state = m.state;
|
|
285
|
+
let recovered = null;
|
|
286
|
+
const markers = opts.recover === false ? [] : turncutPaths(opts.agent ?? seat, opts.project).filter(existsSync);
|
|
287
|
+
if (markers.length) {
|
|
288
|
+
const r = recover(state, { cwd: opts.cwd, turn: state.cursor.turn });
|
|
289
|
+
state = r.state;
|
|
290
|
+
// Persist before clearing the marker: a repair the next turn cannot see is a repair that did
|
|
291
|
+
// not happen, and if this write fails the marker stays and the next read tries again.
|
|
292
|
+
// `rev` is deliberately NOT bumped. The repair restores observations the dead turn already
|
|
293
|
+
// owned, so the caller's expectedRev must still match the disk when it commits its own turn;
|
|
294
|
+
// bumping here would hand every recovered turn a STALE it did nothing to earn.
|
|
295
|
+
const persisted = writeAtomic(path, `${JSON.stringify(state)}\n`);
|
|
296
|
+
if (persisted) for (const f of markers) { try { rmSync(f, { force: true }); } catch { /* another writer got it */ } }
|
|
297
|
+
recovered = { turn: state.cursor.turn, cleared: r.cleared, persisted };
|
|
298
|
+
}
|
|
299
|
+
return { ok: true, state, path, created: false, migrated: m.migrated, recovered };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Keep a copy of a sidecar we refused to upgrade, at `<name>.v<n>.json`. First copy wins. */
|
|
303
|
+
function preserve(path, raw, version) {
|
|
304
|
+
const n = Number.isInteger(version) ? version : "unknown";
|
|
305
|
+
const aside = `${path.slice(0, -".json".length)}.v${n}.json`;
|
|
306
|
+
if (existsSync(aside)) return;
|
|
307
|
+
writeAtomic(aside, raw);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Compare-and-swap write (TDD §4.4). `expectedRev` is the rev the state was READ at; applyTurn has
|
|
312
|
+
* already bumped `state.rev` to expectedRev + 1.
|
|
313
|
+
*
|
|
314
|
+
* The re-read is the whole mechanism: if the on-disk rev has moved, someone else (the seat's own
|
|
315
|
+
* turn, or a `trantor state` repair) wrote between the read and here, and this state was computed
|
|
316
|
+
* against bytes that no longer exist. Returning STALE honestly is the contract; the driver cures it
|
|
317
|
+
* by re-reading and re-running applyTurn, which is safe because ops are id-addressed and the
|
|
318
|
+
* failure modes it would then hit (DUP_ID, NO_SUCH_ID) are exactly the ones re-application means.
|
|
319
|
+
*
|
|
320
|
+
* @param {object} state
|
|
321
|
+
* @param {number} expectedRev
|
|
322
|
+
* @param {{ seat: string, card: number, project: string, ops?: object[] }} opts
|
|
323
|
+
* @returns {{ ok: true, rev: number, path: string } | { ok: false, code: string, at: string, message: string, rev?: number }}
|
|
324
|
+
*/
|
|
325
|
+
export function commit(state, expectedRev, opts = {}) {
|
|
326
|
+
const path = statePath(opts.seat, opts.card ?? state?.card, opts.project);
|
|
327
|
+
if (!path) {
|
|
328
|
+
return { ok: false, code: ERR.SCHEMA, at: "path", message: `no safe sidecar path for seat ${JSON.stringify(opts.seat)} project ${JSON.stringify(opts.project)}` };
|
|
329
|
+
}
|
|
330
|
+
const why = stateError(state);
|
|
331
|
+
if (why) {
|
|
332
|
+
// A store that writes an invalid state has thrown away the only guarantee a reader has.
|
|
333
|
+
return { ok: false, code: ERR.SCHEMA, at: "state", message: `refusing to commit an invalid state: ${why}` };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const onDisk = diskRev(path);
|
|
337
|
+
if (onDisk !== expectedRev) {
|
|
338
|
+
return {
|
|
339
|
+
ok: false, code: STALE, at: "rev", rev: onDisk,
|
|
340
|
+
message: `state moved under this turn: expected rev ${expectedRev}, on disk ${onDisk}. Re-read and re-run applyTurn on the fresh state.`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!writeAtomic(path, `${JSON.stringify(state)}\n`)) {
|
|
345
|
+
return { ok: false, code: ERR.SCHEMA, at: "write", message: `could not write ${path}` };
|
|
346
|
+
}
|
|
347
|
+
if (Array.isArray(opts.ops) && opts.ops.length) {
|
|
348
|
+
appendJournal(opts.seat, opts.card ?? state.card, opts.project, {
|
|
349
|
+
ts: Date.now(), rev: state.rev, turn: state.cursor.turn, by: state.cursor.by, ops: opts.ops,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return { ok: true, rev: state.rev, path };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The rev currently on disk. A sidecar that is missing, unreadable or unparseable reads as 0 —
|
|
356
|
+
* the same rev an empty state carries, so a first write matches and a later one does not. */
|
|
357
|
+
function diskRev(path) {
|
|
358
|
+
try {
|
|
359
|
+
const cur = JSON.parse(readFileSync(path, "utf8"));
|
|
360
|
+
return Number.isInteger(cur?.rev) ? cur.rev : 0;
|
|
361
|
+
} catch {
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Append one accepted patch to the journal ring. FORENSICS AND TEST REPLAY ONLY — nothing in
|
|
368
|
+
* recovery reads this file, and §4.4 says so out loud so a reviewer can check the claim.
|
|
369
|
+
*/
|
|
370
|
+
export function appendJournal(seat, card, project, entry) {
|
|
371
|
+
const path = opsPath(seat, card, project);
|
|
372
|
+
if (!path) return false;
|
|
373
|
+
let lines = [];
|
|
374
|
+
try { lines = readFileSync(path, "utf8").split("\n").filter(Boolean); } catch { lines = []; }
|
|
375
|
+
lines.push(JSON.stringify(entry));
|
|
376
|
+
if (lines.length > JOURNAL_LINES) lines = lines.slice(lines.length - JOURNAL_LINES);
|
|
377
|
+
return writeAtomic(path, `${lines.join("\n")}\n`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Read the journal back. For a forensics tool or a replay test, never for recovery. */
|
|
381
|
+
export function readJournal(seat, card, project) {
|
|
382
|
+
const path = opsPath(seat, card, project);
|
|
383
|
+
if (!path) return [];
|
|
384
|
+
let raw;
|
|
385
|
+
try { raw = readFileSync(path, "utf8"); } catch { return []; }
|
|
386
|
+
const out = [];
|
|
387
|
+
for (const line of raw.split("\n")) {
|
|
388
|
+
if (!line) continue;
|
|
389
|
+
try { out.push(JSON.parse(line)); } catch { /* a torn tail is a forensics artefact, not a fault */ }
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Collect sidecars whose card is in a terminal status and whose file has not moved in GC_AGE_MS.
|
|
396
|
+
* The store does not know what "terminal" means — that is the board's word — so the caller passes
|
|
397
|
+
* the predicate. Nothing is removed unless `apply` is set: the default answer to "which of these
|
|
398
|
+
* could go" is a list, not a deletion.
|
|
399
|
+
*
|
|
400
|
+
* @param {{ project: string, isTerminal: (card: number) => boolean, now?: number,
|
|
401
|
+
* maxAgeMs?: number, apply?: boolean }} opts
|
|
402
|
+
* @returns {{ candidates: { path: string, seat: string, card: number, ageMs: number }[], removed: string[] }}
|
|
403
|
+
*/
|
|
404
|
+
export function gcSidecars(opts = {}) {
|
|
405
|
+
const dir = stateDir(opts.project);
|
|
406
|
+
const isTerminal = opts.isTerminal || (() => false);
|
|
407
|
+
const now = Number.isInteger(opts.now) ? opts.now : Date.now();
|
|
408
|
+
const maxAgeMs = Number.isInteger(opts.maxAgeMs) ? opts.maxAgeMs : GC_AGE_MS;
|
|
409
|
+
const candidates = [], removed = [];
|
|
410
|
+
if (!dir || !existsSync(dir)) return { candidates, removed };
|
|
411
|
+
|
|
412
|
+
for (const name of readdirSync(dir)) {
|
|
413
|
+
const m = /^(.+)--(\d+)\.json$/.exec(name);
|
|
414
|
+
if (!m) continue; // .ops.jsonl and preserved .v<n>.json are not sidecars
|
|
415
|
+
const path = join(dir, name);
|
|
416
|
+
let ageMs;
|
|
417
|
+
try { ageMs = now - statSync(path).mtimeMs; } catch { continue; }
|
|
418
|
+
if (ageMs < maxAgeMs) continue;
|
|
419
|
+
const card = Number(m[2]);
|
|
420
|
+
if (!isTerminal(card)) continue;
|
|
421
|
+
candidates.push({ path, seat: m[1], card, ageMs });
|
|
422
|
+
}
|
|
423
|
+
if (!opts.apply) return { candidates, removed };
|
|
424
|
+
|
|
425
|
+
for (const c of candidates) {
|
|
426
|
+
const stem = c.path.slice(0, -".json".length);
|
|
427
|
+
for (const f of [c.path, `${stem}.ops.jsonl`, ...preservedFor(dir, stem)]) {
|
|
428
|
+
try { if (existsSync(f)) { rmSync(f, { force: true }); removed.push(f); } } catch { /* leave it for the next sweep */ }
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { candidates, removed };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The `.v<n>.json` copies preserved beside one sidecar, so gc takes the whole set or none of it. */
|
|
435
|
+
function preservedFor(dir, stem) {
|
|
436
|
+
const prefix = `${stem.slice(dir.length + 1)}.v`;
|
|
437
|
+
try {
|
|
438
|
+
return readdirSync(dir).filter(n => n.startsWith(prefix) && n.endsWith(".json")).map(n => join(dir, n));
|
|
439
|
+
} catch {
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: the validator's whole job is decoding an untrusted TurnResult at the boundary — a model may emit any JSON at all, and every typeof here is that parse, returning a named rejection rather than narrowing a value that was never established. */
|
|
2
|
+
// Trantor State — the validator (TDD §4.2, stages 1-3). Pure and total: same arguments, same
|
|
3
|
+
// answer, no disk, no clock, no throw. Rejection is a first-class RETURN, not an exception,
|
|
4
|
+
// because the rejection text is fed back to the seat as its next observation.
|
|
5
|
+
import {
|
|
6
|
+
CAPS, LISTS, COMPACTING_LISTS, ERR, EVIDENCE_MARKER, SETTABLE_FIELDS, READONLY_FIELDS,
|
|
7
|
+
RUNTIME_EXT_KEYS, KNOWN_FIELDS, itemError, isAction,
|
|
8
|
+
} from "./schema.mjs";
|
|
9
|
+
|
|
10
|
+
const reject = (code, at, message) => ({ ok: false, code, at, message });
|
|
11
|
+
|
|
12
|
+
/** Which top-level field a `set` path addresses: "ext.foo" → "ext". */
|
|
13
|
+
const rootOf = (field) => String(field).split(".")[0];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pull the evidence marker out of an item's text BEFORE any cap is applied, and merge it with any
|
|
17
|
+
* `paths` the patch supplied directly (TDD §3: the two routes converge on the same field).
|
|
18
|
+
* Order is the whole point: extract, then cap, so a long text can never truncate away the paths
|
|
19
|
+
* route (b) depends on.
|
|
20
|
+
* @returns {{ ok: true, item: object } | { ok: false, reason: string }}
|
|
21
|
+
*/
|
|
22
|
+
export function extractEvidence(item) {
|
|
23
|
+
const marked = EVIDENCE_MARKER.exec(item.text);
|
|
24
|
+
const fromMarker = marked ? marked[1].split(",").filter(Boolean) : [];
|
|
25
|
+
const prose = marked ? item.text.slice(0, marked.index) : item.text;
|
|
26
|
+
const paths = [...new Set([...(item.paths || []), ...fromMarker])];
|
|
27
|
+
|
|
28
|
+
// A malformed marker is rejected naming the marker, never accepted-and-trimmed into silence.
|
|
29
|
+
if (paths.length > CAPS.ITEM_PATHS) {
|
|
30
|
+
return { ok: false, reason: `evidence marker carries ${paths.length} paths, CAPS.ITEM_PATHS is ${CAPS.ITEM_PATHS}` };
|
|
31
|
+
}
|
|
32
|
+
for (const p of paths) {
|
|
33
|
+
if (p.length > CAPS.PATH) return { ok: false, reason: `evidence path exceeds CAPS.PATH (${CAPS.PATH}): "${p.slice(0, 60)}…"` };
|
|
34
|
+
if (p.startsWith("/") || p.split("/").includes("..")) {
|
|
35
|
+
return { ok: false, reason: `evidence path escapes the worktree: "${p}"` };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const out = { id: item.id, text: prose.slice(0, CAPS.ITEM) };
|
|
40
|
+
if (paths.length) out.paths = paths;
|
|
41
|
+
return { ok: true, item: out };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Does this item have the evidence a `move → done` needs (TDD §4.2)?
|
|
46
|
+
* Route (a): the gate went green this turn. Route (b): every path it names is credited.
|
|
47
|
+
*/
|
|
48
|
+
export function hasEvidence(state, item) {
|
|
49
|
+
if (state.verify.tested === true && state.verify.exit === 0) return true;
|
|
50
|
+
const paths = item.paths || [];
|
|
51
|
+
if (!paths.length) return false;
|
|
52
|
+
return paths.every(p => state.files[p]?.verified === true);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stages 1-3. On success returns the normalised ops (markers extracted, prose capped) for apply.mjs
|
|
57
|
+
* to run; on failure the rejection the seat will read.
|
|
58
|
+
*
|
|
59
|
+
* `ctx.gate_attempted` is what splits NEEDS_GATE from UNVERIFIED_DONE, and it is the difference
|
|
60
|
+
* between "no gate has run" and "a gate ran and came back red" — two causes that look identical in
|
|
61
|
+
* the state. Without it the core returns NEEDS_GATE both times and §4.8's one-retry bound is false.
|
|
62
|
+
*/
|
|
63
|
+
export function validateTurn(state, turn, ctx = {}) {
|
|
64
|
+
// ---- stage 1: shape ----
|
|
65
|
+
if (typeof turn !== "object" || turn === null || Array.isArray(turn)) {
|
|
66
|
+
return reject(ERR.SCHEMA, "turn", "TurnResult must be an object with { patch, action }");
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(turn.patch)) {
|
|
69
|
+
return reject(ERR.SCHEMA, "turn.patch", "TurnResult.patch must be an array of ops");
|
|
70
|
+
}
|
|
71
|
+
if (!isAction(turn.action)) {
|
|
72
|
+
return reject(ERR.BAD_ACTION, "turn.action",
|
|
73
|
+
"every turn must act, finish, or ask: one of { tool, input } | { done: true } | { ask } | { continue: true }");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const OPERATORS = ["set", "add", "remove", "move"];
|
|
77
|
+
const normalised = [];
|
|
78
|
+
|
|
79
|
+
// Lengths are simulated as we go, so an `add` that would overflow a working list is caught
|
|
80
|
+
// before anything is applied — all-or-nothing is what makes "no valid patch corrupts state"
|
|
81
|
+
// testable in the first place.
|
|
82
|
+
const lengths = Object.fromEntries(LISTS.map(l => [l, state[l].length]));
|
|
83
|
+
// id → which list holds it, and id → the item itself. Both are simulated forward through the
|
|
84
|
+
// patch so ops later in the same patch see what the earlier ones did.
|
|
85
|
+
const listOf = new Map();
|
|
86
|
+
const itemOf = new Map();
|
|
87
|
+
for (const l of LISTS) for (const it of state[l]) { listOf.set(it.id, l); itemOf.set(it.id, it); }
|
|
88
|
+
let taskSet = state.task !== "";
|
|
89
|
+
|
|
90
|
+
for (let n = 0; n < turn.patch.length; n++) {
|
|
91
|
+
const op = turn.patch[n];
|
|
92
|
+
if (typeof op !== "object" || op === null || Array.isArray(op)) {
|
|
93
|
+
return reject(ERR.SCHEMA, `patch[${n}]`, "each op must be an object");
|
|
94
|
+
}
|
|
95
|
+
const keys = Object.keys(op).filter(k => OPERATORS.includes(k));
|
|
96
|
+
if (keys.length !== 1 || Object.keys(op).length !== 1) {
|
|
97
|
+
return reject(ERR.SCHEMA, `patch[${n}]`,
|
|
98
|
+
`each op has exactly one operator key (${OPERATORS.join(" | ")}); got [${Object.keys(op).join(", ")}]`);
|
|
99
|
+
}
|
|
100
|
+
const kind = keys[0];
|
|
101
|
+
const body = op[kind];
|
|
102
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
103
|
+
return reject(ERR.SCHEMA, `patch[${n}].${kind}`, `${kind} takes an object`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (kind === "set") {
|
|
107
|
+
const { field, value } = body;
|
|
108
|
+
if (typeof field !== "string" || !field) {
|
|
109
|
+
return reject(ERR.SCHEMA, `patch[${n}].set`, "set.field must be a non-empty string");
|
|
110
|
+
}
|
|
111
|
+
const root = rootOf(field);
|
|
112
|
+
// ---- stage 2: the write matrix. One table lookup, and it is the whole "a seat marks its
|
|
113
|
+
// own work verified" hole.
|
|
114
|
+
if (READONLY_FIELDS.includes(root)) {
|
|
115
|
+
return reject(ERR.READONLY_FIELD, `set:${field}`,
|
|
116
|
+
`${field} is harness-written and never model-writable. Evidence comes from a gate that ran, not from the patch.`);
|
|
117
|
+
}
|
|
118
|
+
if (root === "ext" && RUNTIME_EXT_KEYS.includes(field.split(".")[1])) {
|
|
119
|
+
return reject(ERR.READONLY_FIELD, `set:${field}`, `ext.${field.split(".")[1]} is runtime-owned`);
|
|
120
|
+
}
|
|
121
|
+
if (LISTS.includes(root)) {
|
|
122
|
+
return reject(ERR.READONLY_FIELD, `set:${field}`,
|
|
123
|
+
`${root} changes by add/remove/move only, never by set — arrays change only by id`);
|
|
124
|
+
}
|
|
125
|
+
if (!KNOWN_FIELDS.includes(root)) {
|
|
126
|
+
return reject(ERR.UNKNOWN_FIELD, `set:${field}`,
|
|
127
|
+
`the schema defines no field "${root}". An unknown field is a rejection, never a silently created key.`);
|
|
128
|
+
}
|
|
129
|
+
if (!SETTABLE_FIELDS.includes(root)) {
|
|
130
|
+
return reject(ERR.READONLY_FIELD, `set:${field}`, `${field} is not settable`);
|
|
131
|
+
}
|
|
132
|
+
if (root === "task") {
|
|
133
|
+
if (field !== "task") return reject(ERR.UNKNOWN_FIELD, `set:${field}`, "task is a scalar; set it whole");
|
|
134
|
+
if (typeof value !== "string") return reject(ERR.SCHEMA, `set:${field}`, "task must be a string");
|
|
135
|
+
if (taskSet) return reject(ERR.READONLY_FIELD, "set:task", "task is set-once: it is writable only while empty");
|
|
136
|
+
if (value.length > CAPS.TASK) return reject(ERR.CAP, "set:task", `task exceeds CAPS.TASK (${CAPS.TASK})`);
|
|
137
|
+
taskSet = value !== "";
|
|
138
|
+
}
|
|
139
|
+
if (root === "notes") {
|
|
140
|
+
if (typeof value !== "string") return reject(ERR.SCHEMA, `set:${field}`, "notes must be a string");
|
|
141
|
+
}
|
|
142
|
+
normalised.push({ set: { field, value } });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (kind === "add") {
|
|
147
|
+
const { list, item } = body;
|
|
148
|
+
if (!LISTS.includes(list)) {
|
|
149
|
+
return reject(ERR.UNKNOWN_LIST, `patch[${n}].add`, `no list "${list}"; one of ${LISTS.join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
const why = itemError(item);
|
|
152
|
+
if (why) return reject(ERR.SCHEMA, `add:${list}`, why);
|
|
153
|
+
if (item.id.length > CAPS.ID) {
|
|
154
|
+
return reject(ERR.CAP, `add:${item.id.slice(0, 16)}…`,
|
|
155
|
+
`item.id exceeds CAPS.ID (${CAPS.ID}). Over-long ids are rejected, not trimmed — a trimmed id no longer addresses the item the next op names.`);
|
|
156
|
+
}
|
|
157
|
+
if (listOf.has(item.id)) return reject(ERR.DUP_ID, `add:${item.id}`, `id "${item.id}" already exists`);
|
|
158
|
+
const ev = extractEvidence(item);
|
|
159
|
+
if (!ev.ok) return reject(ERR.CAP, `add:${item.id}`, ev.reason);
|
|
160
|
+
if (lengths[list] + 1 > CAPS.LIST) {
|
|
161
|
+
if (!COMPACTING_LISTS.includes(list)) {
|
|
162
|
+
return reject(ERR.CAP, `add:${list}`,
|
|
163
|
+
`${list} is at CAPS.LIST (${CAPS.LIST}) and is a working list: overflow is a rejection, not a silent drop`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
listOf.set(item.id, list);
|
|
167
|
+
itemOf.set(item.id, ev.item);
|
|
168
|
+
lengths[list]++;
|
|
169
|
+
normalised.push({ add: { list, item: ev.item } });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (kind === "remove") {
|
|
174
|
+
const { list, id } = body;
|
|
175
|
+
if (!LISTS.includes(list)) {
|
|
176
|
+
return reject(ERR.UNKNOWN_LIST, `patch[${n}].remove`, `no list "${list}"; one of ${LISTS.join(", ")}`);
|
|
177
|
+
}
|
|
178
|
+
if (typeof id !== "string" || !id) return reject(ERR.SCHEMA, `patch[${n}].remove`, "remove.id must be a non-empty string");
|
|
179
|
+
if (listOf.get(id) !== list) {
|
|
180
|
+
return reject(ERR.NO_SUCH_ID, `remove:${id}`,
|
|
181
|
+
listOf.has(id) ? `item "${id}" is in ${listOf.get(id)}, not ${list}` : `no item "${id}" to remove`);
|
|
182
|
+
}
|
|
183
|
+
listOf.delete(id);
|
|
184
|
+
itemOf.delete(id);
|
|
185
|
+
lengths[list]--;
|
|
186
|
+
normalised.push({ remove: { list, id } });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// move
|
|
191
|
+
const { id, from, to } = body;
|
|
192
|
+
if (!LISTS.includes(from) || !LISTS.includes(to)) {
|
|
193
|
+
return reject(ERR.UNKNOWN_LIST, `patch[${n}].move`, `move needs two known lists; got "${from}" → "${to}"`);
|
|
194
|
+
}
|
|
195
|
+
if (typeof id !== "string" || !id) return reject(ERR.SCHEMA, `patch[${n}].move`, "move.id must be a non-empty string");
|
|
196
|
+
if (listOf.get(id) !== from) {
|
|
197
|
+
return reject(ERR.NO_SUCH_ID, `move:${id}`,
|
|
198
|
+
listOf.has(id) ? `item "${id}" is in ${listOf.get(id)}, not ${from}` : `no item "${id}" to move`);
|
|
199
|
+
}
|
|
200
|
+
if (lengths[to] + 1 > CAPS.LIST && !COMPACTING_LISTS.includes(to)) {
|
|
201
|
+
return reject(ERR.CAP, `move:${id}`, `${to} is at CAPS.LIST (${CAPS.LIST})`);
|
|
202
|
+
}
|
|
203
|
+
if (to === "done") {
|
|
204
|
+
const item = itemOf.get(id);
|
|
205
|
+
if (item && !hasEvidence(state, item)) {
|
|
206
|
+
// The split that makes §4.8's one-retry bound a fact: a gate that ran and failed looks
|
|
207
|
+
// exactly like a gate that never ran, unless the driver says which happened.
|
|
208
|
+
if (ctx.gate_attempted) {
|
|
209
|
+
const g = ctx.gate_attempted;
|
|
210
|
+
return {
|
|
211
|
+
...reject(ERR.UNVERIFIED_DONE, `move:${id}`,
|
|
212
|
+
`move ${id} → done rejected: the gate ran and did not pass (${g.cmd || "gate"}, exit ${g.exit}). ` +
|
|
213
|
+
`Fix the failure, then move it.`),
|
|
214
|
+
gate: { cmd: g.cmd, exit: g.exit, tail: g.tail },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
...reject(ERR.NEEDS_GATE, `move:${id}`,
|
|
219
|
+
`move ${id} → done needs evidence: no gate has run at this state. Run it, then re-apply.`),
|
|
220
|
+
gate: { items: [id], paths: item.paths || [] },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
listOf.set(id, to);
|
|
225
|
+
lengths[from]--;
|
|
226
|
+
lengths[to]++;
|
|
227
|
+
normalised.push({ move: { id, from, to } });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { ok: true, ops: normalised };
|
|
231
|
+
}
|