catherd-cli 1.0.0 → 1.1.0
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/README.md +66 -42
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +112 -95
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +63 -0
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +95 -1
- package/src/adapters/codex/index.ts +78 -23
- package/src/adapters/opencode/index.ts +17 -0
- package/src/domain/errors.ts +2 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/lane.ts +23 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +43 -9
- package/src/domain/profile.ts +12 -6
- package/src/domain/record.ts +2 -0
- package/src/domain/role-prompts.ts +33 -1
- package/src/domain/state.ts +18 -2
- package/src/entry/deps.ts +2 -1
- package/src/entry/doctor-command.ts +7 -1
- package/src/entry/glyphs.ts +2 -1
- package/src/entry/init-command.ts +41 -1
- package/src/entry/mcp/command.ts +5 -1
- package/src/entry/mcp/dispatch-tools.ts +10 -40
- package/src/entry/mcp/handshake.ts +19 -9
- package/src/entry/mcp/lane-tools.ts +7 -6
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/run-tools.ts +4 -3
- package/src/entry/mcp/server.ts +17 -1
- package/src/entry/mcp/setup-tools.ts +1 -1
- package/src/entry/profile-command.ts +3 -5
- package/src/entry/runs-command.ts +64 -21
- package/src/entry/tui/commands.ts +1 -1
- package/src/entry/tui/effects.ts +98 -56
- package/src/entry/tui/fixtures.ts +352 -85
- package/src/entry/tui/profile-edits.ts +2 -2
- package/src/entry/tui/profile-tree.ts +2 -1
- package/src/entry/tui/providers/data.tsx +35 -8
- package/src/entry/tui/state.ts +26 -6
- package/src/entry/tui/stories.ts +36 -4
- package/src/entry/tui/text.ts +12 -0
- package/src/entry/tui/theme.ts +1 -0
- package/src/entry/tui/views/runs.tsx +409 -117
- package/src/entry/tui/views/status.tsx +5 -2
- package/src/entry/tui/widgets/list.tsx +4 -3
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +14 -6
- package/src/infra/env.ts +26 -2
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/supervisor.ts +33 -3
- package/src/services/admission.ts +38 -12
- package/src/services/dispatch-service.ts +338 -197
- package/src/services/dispatches.ts +30 -1
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +3 -18
- package/src/services/doctor-checks.ts +46 -1
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +59 -79
- package/src/services/finalize.ts +43 -25
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/lane-service.ts +150 -8
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +7 -4
- package/src/services/preflight.ts +17 -3
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +40 -11
- package/src/services/reentry.ts +22 -0
- package/src/services/run-service.ts +42 -9
- package/src/services/run-store.ts +18 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/state.ts +10 -3
- package/src/services/summary.ts +14 -0
package/src/services/doctor.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { adapterFor } from "../adapters/registry.ts";
|
|
3
3
|
import "../adapters/all.ts";
|
|
4
|
-
import type
|
|
5
|
-
import { ROLES } from "../domain/roles.ts";
|
|
4
|
+
import { BUILTIN_ROLES, type Profile } from "../domain/profile.ts";
|
|
5
|
+
import { DEFAULT_ACCESS, ROLES } from "../domain/roles.ts";
|
|
6
6
|
import { bunTooOld, MIN_BUN } from "../domain/runtime.ts";
|
|
7
7
|
import type { JevTransport } from "../infra/jev-client.ts";
|
|
8
|
-
import { locksDir } from "../infra/paths.ts";
|
|
9
8
|
import { linkedProfiles } from "./agent-links.ts";
|
|
10
|
-
import {
|
|
9
|
+
import { accessChecks } from "./doctor-access.ts";
|
|
10
|
+
import { backendChecks, usedBackends } from "./doctor-backends.ts";
|
|
11
|
+
import { type PushProbe, pushCheck } from "./doctor-push.ts";
|
|
11
12
|
import {
|
|
12
13
|
agentsCheck,
|
|
13
14
|
type Check,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
fixOf,
|
|
16
17
|
guarded,
|
|
17
18
|
locksCheck,
|
|
19
|
+
mcpCheck,
|
|
18
20
|
pluginCheck,
|
|
19
21
|
} from "./doctor-checks.ts";
|
|
20
22
|
import { credentialsPath, jevKey, savedJevKey, testJevKey } from "./jev-service.ts";
|
|
@@ -40,14 +42,18 @@ export interface Handshake {
|
|
|
40
42
|
ok: boolean;
|
|
41
43
|
tools: string[];
|
|
42
44
|
error?: string;
|
|
45
|
+
/** the tail of what the server (or its launcher) wrote to stderr */
|
|
46
|
+
stderr?: string;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
export interface DoctorDeps {
|
|
46
50
|
bunVersion: string;
|
|
47
51
|
/** the package version, which the Claude Code plugin must pin */
|
|
48
52
|
version: string;
|
|
49
|
-
/** starts
|
|
53
|
+
/** starts the MCP server as the plugin does (its launcher) over stdio and asks it for tools/list */
|
|
50
54
|
handshake: () => Promise<Handshake>;
|
|
55
|
+
/** spec §3.9: sends this Claude Code session a test message; without it (the dashboard) there is no `push` row */
|
|
56
|
+
push?: () => Promise<PushProbe>;
|
|
51
57
|
jev?: JevTransport;
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -145,7 +151,8 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
const used = usedBackends(profiles);
|
|
148
|
-
|
|
154
|
+
const installed = new Set<string>();
|
|
155
|
+
checks.push(...(await backendChecks(used, profiles, installed)));
|
|
149
156
|
|
|
150
157
|
if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
|
|
151
158
|
checks.push({
|
|
@@ -200,88 +207,59 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
200
207
|
const h = await d
|
|
201
208
|
.handshake()
|
|
202
209
|
.catch((e: unknown): Handshake => ({ ok: false, tools: [], error: errText(e) }));
|
|
203
|
-
checks.push(
|
|
204
|
-
h.ok && h.tools.includes("status")
|
|
205
|
-
? {
|
|
206
|
-
id: "mcp",
|
|
207
|
-
label: "MCP server",
|
|
208
|
-
state: "ok",
|
|
209
|
-
word: "ready",
|
|
210
|
-
detail: `answers tools/list with ${h.tools.length} tools`,
|
|
211
|
-
}
|
|
212
|
-
: {
|
|
213
|
-
id: "mcp",
|
|
214
|
-
label: "MCP server",
|
|
215
|
-
state: "fail",
|
|
216
|
-
word: "no answer",
|
|
217
|
-
detail: h.error ?? "tools/list has no status tool",
|
|
218
|
-
fix: "run catherd mcp to see why it does not start",
|
|
219
|
-
},
|
|
220
|
-
);
|
|
210
|
+
checks.push(mcpCheck(h, d.version));
|
|
221
211
|
|
|
222
|
-
|
|
223
|
-
for (const id of workspaceWriteBackends(profiles)) {
|
|
224
|
-
const a = adapterFor(id);
|
|
225
|
-
if (!a?.canWrite) continue;
|
|
226
|
-
const r = await a.canWrite(locksDir()).catch(() => null);
|
|
227
|
-
const base = { id: `sandbox:${id}`, label: `heavy-lock dir from ${id}'s sandbox` };
|
|
212
|
+
if (d.push)
|
|
228
213
|
checks.push(
|
|
229
|
-
|
|
230
|
-
? {
|
|
231
|
-
...base,
|
|
232
|
-
state: "skip",
|
|
233
|
-
word: "not tested",
|
|
234
|
-
detail: `no ${id} sandbox to test with on this machine`,
|
|
235
|
-
}
|
|
236
|
-
: r.ok
|
|
237
|
-
? {
|
|
238
|
-
...base,
|
|
239
|
-
state: "ok",
|
|
240
|
-
word: "ready",
|
|
241
|
-
detail: "a workspace-write worker can take the heavy lock",
|
|
242
|
-
}
|
|
243
|
-
: {
|
|
244
|
-
...base,
|
|
245
|
-
state: "warn",
|
|
246
|
-
word: "not writable",
|
|
247
|
-
detail: `a workspace-write ${id} worker cannot write ${locksDir()}, so catherd lock fails inside it`,
|
|
248
|
-
...(r.fix ? { fix: r.fix } : {}),
|
|
249
|
-
},
|
|
214
|
+
pushCheck(await d.push().catch((e: unknown): PushProbe => ({ outcome: "failed", detail: errText(e) }))),
|
|
250
215
|
);
|
|
251
|
-
}
|
|
252
216
|
|
|
253
|
-
|
|
254
|
-
|
|
217
|
+
checks.push(locksCheck());
|
|
218
|
+
// spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
|
|
219
|
+
checks.push(...(await accessChecks(profiles, installed)));
|
|
220
|
+
|
|
221
|
+
// spec 1.1 §13: what the shipped defaults do is info; a warning only for what a profile changed
|
|
222
|
+
const full = { changed: [] as string[], shipped: [] as string[] };
|
|
223
|
+
const advisory = { changed: [] as string[], shipped: [] as string[] };
|
|
224
|
+
const backendOf = (r: string) => r.slice(0, r.indexOf(":"));
|
|
255
225
|
for (const p of profiles)
|
|
256
226
|
for (const role of ROLES) {
|
|
257
227
|
const rc = p.roles[role];
|
|
258
228
|
if (!rc.enabled) continue;
|
|
259
|
-
|
|
229
|
+
const asShipped = rc.access === DEFAULT_ACCESS[role];
|
|
230
|
+
if (rc.access === "full") (asShipped ? full.shipped : full.changed).push(`${role} (${p.name})`);
|
|
260
231
|
const soft = [
|
|
261
|
-
...new Set(
|
|
262
|
-
rc.rungs
|
|
263
|
-
.filter((r) => enforcementOf(r, rc.access) === "advisory")
|
|
264
|
-
.map((r) => r.slice(0, r.indexOf(":"))),
|
|
265
|
-
),
|
|
232
|
+
...new Set(rc.rungs.filter((r) => enforcementOf(r, rc.access) === "advisory").map(backendOf)),
|
|
266
233
|
];
|
|
267
|
-
|
|
234
|
+
const shippedOn = new Set(BUILTIN_ROLES[role].rungs.map(backendOf));
|
|
235
|
+
const ours = soft.filter((b) => asShipped && shippedOn.has(b));
|
|
236
|
+
const theirs = soft.filter((b) => !ours.includes(b));
|
|
237
|
+
if (ours.length) advisory.shipped.push(`${role} on ${ours.join(", ")} (${p.name})`);
|
|
238
|
+
if (theirs.length) advisory.changed.push(`${role} on ${theirs.join(", ")} (${p.name})`);
|
|
268
239
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
240
|
+
const accessRow = (
|
|
241
|
+
id: string,
|
|
242
|
+
label: string,
|
|
243
|
+
rows: { changed: string[]; shipped: string[] },
|
|
244
|
+
what: string,
|
|
245
|
+
): Check | null => {
|
|
246
|
+
if (!rows.changed.length && !rows.shipped.length) return null;
|
|
247
|
+
const shipped = rows.shipped.join(", ");
|
|
248
|
+
return rows.changed.length
|
|
249
|
+
? {
|
|
250
|
+
id,
|
|
251
|
+
label,
|
|
252
|
+
state: "warn",
|
|
253
|
+
word: "warning",
|
|
254
|
+
detail: `${what}: ${rows.changed.join(", ")}${shipped ? `; as shipped: ${shipped}` : ""}`,
|
|
255
|
+
}
|
|
256
|
+
: { id, label, state: "info", word: "default", detail: `${what}: ${shipped}` };
|
|
257
|
+
};
|
|
258
|
+
for (const row of [
|
|
259
|
+
accessRow("access:full", "full access", full, "no sandbox for"),
|
|
260
|
+
accessRow("access:advisory", "advisory access", advisory, "the backend asks but cannot force"),
|
|
261
|
+
])
|
|
262
|
+
if (row) checks.push(row);
|
|
285
263
|
for (const id of used.keys()) {
|
|
286
264
|
const note = adapterFor(id)?.isolationNote;
|
|
287
265
|
if (note)
|
|
@@ -322,13 +300,15 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
322
300
|
);
|
|
323
301
|
}
|
|
324
302
|
|
|
325
|
-
|
|
303
|
+
// the plugin's launcher needs bunx only when no global catherd is installed
|
|
304
|
+
const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH ?? "" });
|
|
305
|
+
if (!onPath("bunx") && !onPath("catherd"))
|
|
326
306
|
checks.push({
|
|
327
307
|
id: "bunx",
|
|
328
308
|
label: "bunx",
|
|
329
309
|
state: "warn",
|
|
330
310
|
word: "missing",
|
|
331
|
-
detail: "the plugin
|
|
311
|
+
detail: "the plugin's MCP launcher runs bunx when no global catherd is installed",
|
|
332
312
|
fix: "put Bun's bin folder (~/.bun/bin) on PATH",
|
|
333
313
|
});
|
|
334
314
|
let corrupt: ReturnType<typeof listRuns>["corrupt"] = [];
|
package/src/services/finalize.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
writeTextAtomic,
|
|
26
26
|
} from "../infra/store.ts";
|
|
27
27
|
import { type Dispatch, dispatchState, listDispatches, readProc } from "./dispatches.ts";
|
|
28
|
+
import { tail } from "./run-debug.ts";
|
|
28
29
|
import { appendRecord, readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
|
|
29
30
|
|
|
30
31
|
const text = (file: string): string => {
|
|
@@ -55,6 +56,15 @@ function claimStale(dir: string): boolean {
|
|
|
55
56
|
return claimAgeMs(dir) > settleLimits.timeoutMs + gitLimits.timeoutMs + settleLimits.claimMarginMs;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Whether another live process holds this dispatch's finalizer claim (not yet stale): that process writes its
|
|
61
|
+
* record and settles it, so a recovery pass (`claim`) leaves it alone rather than wait on it.
|
|
62
|
+
*/
|
|
63
|
+
export function finalizingElsewhere(dir: string): boolean {
|
|
64
|
+
const who = readClaimant(dir);
|
|
65
|
+
return who !== null && who.pid !== process.pid && !claimStale(dir);
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
/** The owned paths of the run's other dispatches whose lifetime overlapped [start, end]. */
|
|
59
69
|
function othersOwns(run: Run, self: Dispatch, start: number, end: number): string[] {
|
|
60
70
|
const ended = new Map(readRecords(run).records.map((r) => [r.dispatchId, Date.parse(r.endedAt)]));
|
|
@@ -215,6 +225,7 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
215
225
|
images: o.images,
|
|
216
226
|
error: o.error,
|
|
217
227
|
replyPath: relative(run.dir, p.reply),
|
|
228
|
+
...(a.sessionId ? { sessionId: a.sessionId } : {}),
|
|
218
229
|
};
|
|
219
230
|
}
|
|
220
231
|
|
|
@@ -252,7 +263,7 @@ function recordHarness(run: Run, d: Dispatch, r: RunRecord): void {
|
|
|
252
263
|
* appendRecord's per-dispatch dedupe keeps that to one record either way (audit C2). A claimer that throws
|
|
253
264
|
* releases its claim.
|
|
254
265
|
*
|
|
255
|
-
* In this process, a second finalizer (
|
|
266
|
+
* In this process, a second finalizer (`cancel` beside the dispatch's watcher) joins the first one's
|
|
256
267
|
* promise. Across processes it waits for the claimant's record, and takes over only once the claim is
|
|
257
268
|
* stale (claimant dead, or past its settle window): never while a live claimant may still be settling.
|
|
258
269
|
*/
|
|
@@ -321,39 +332,46 @@ async function finalizeOnce(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
321
332
|
return saved;
|
|
322
333
|
}
|
|
323
334
|
|
|
324
|
-
/**
|
|
325
|
-
export
|
|
335
|
+
/** How much of a role's last event `peek` and the runs page show (spec §3.7). */
|
|
336
|
+
export const LAST_EVENT_CHARS = 160;
|
|
337
|
+
|
|
338
|
+
const oneLine = (s: string): string => {
|
|
339
|
+
const line = s.split("\n").find((l) => l.trim()) ?? "";
|
|
340
|
+
return line.length > LAST_EVENT_CHARS ? `${line.slice(0, LAST_EVENT_CHARS - 1)}…` : line;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Spec §3.7: what a live role is doing, from the end of its events.jsonl: the last line its adapter reads as an
|
|
345
|
+
* activity (a command, a file edit, a message line), else the last event's name; null before any event.
|
|
346
|
+
*/
|
|
347
|
+
export function lastActivity(d: Dispatch): string | null {
|
|
326
348
|
const a = adapterFor(d.admit.backend);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
349
|
+
if (!a) return null;
|
|
350
|
+
let name: string | null = null;
|
|
351
|
+
for (const line of tail(dispatchPaths(d.dir).events, 200).toReversed()) {
|
|
352
|
+
let delta;
|
|
353
|
+
try {
|
|
354
|
+
delta = a.parse(line);
|
|
355
|
+
} catch {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (delta.activity) return oneLine(delta.activity);
|
|
359
|
+
name ??= delta.lastEvent ?? null;
|
|
333
360
|
}
|
|
361
|
+
return name;
|
|
334
362
|
}
|
|
335
363
|
|
|
336
|
-
/** Waits until the dispatch finishes,
|
|
364
|
+
/** Waits until the dispatch finishes, polling every `pollMs`; `onPoll` runs at each poll, and never ends the wait. */
|
|
337
365
|
export async function waitForFinish(
|
|
338
366
|
d: Dispatch,
|
|
339
|
-
o: {
|
|
340
|
-
pollMs: number;
|
|
341
|
-
tickMs: number;
|
|
342
|
-
now: () => number;
|
|
343
|
-
onTick?: (secs: number, lastEvent: string | null) => void;
|
|
344
|
-
},
|
|
367
|
+
o: { pollMs: number; now: () => number; onPoll?: () => void },
|
|
345
368
|
): Promise<void> {
|
|
346
|
-
const started = Date.now();
|
|
347
|
-
let ticked = started;
|
|
348
369
|
while (dispatchState(d, o.now()) !== "finished") {
|
|
349
370
|
await Bun.sleep(o.pollMs);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
} catch {
|
|
355
|
-
// a progress report must never stop the wait
|
|
356
|
-
}
|
|
371
|
+
try {
|
|
372
|
+
o.onPoll?.();
|
|
373
|
+
} catch {
|
|
374
|
+
// a report on the way must never stop the wait
|
|
357
375
|
}
|
|
358
376
|
}
|
|
359
377
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readlinkSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
type Stats,
|
|
8
|
+
statSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { CatherdError, isCatherdError } from "../domain/errors.ts";
|
|
13
|
+
import { normalizeOwned, overlaps } from "../domain/lane.ts";
|
|
14
|
+
import { git, gitHead, statusSnapshot } from "../infra/git.ts";
|
|
15
|
+
import { repoDir } from "../infra/paths.ts";
|
|
16
|
+
import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
|
|
17
|
+
import type { Deps } from "./ports.ts";
|
|
18
|
+
import { findRun, type Run } from "./run-store.ts";
|
|
19
|
+
|
|
20
|
+
// Spec 1.1 §7: the verifier's gate ledger. A gate item that passed on the same content, with the same
|
|
21
|
+
// command, is carried over instead of run again; every check is a verifier step `peek` and `status` show.
|
|
22
|
+
|
|
23
|
+
const GatePassSchema = z.looseObject({
|
|
24
|
+
at: z.string(),
|
|
25
|
+
run: z.string(),
|
|
26
|
+
item: z.string(),
|
|
27
|
+
command: z.string(),
|
|
28
|
+
paths: z.array(z.string()),
|
|
29
|
+
hash: z.string(),
|
|
30
|
+
commit: z.string(),
|
|
31
|
+
evidence: z.string(),
|
|
32
|
+
});
|
|
33
|
+
type GatePass = z.infer<typeof GatePassSchema>;
|
|
34
|
+
|
|
35
|
+
export interface VerifierStep {
|
|
36
|
+
at: string;
|
|
37
|
+
item: string;
|
|
38
|
+
carried: boolean;
|
|
39
|
+
/** a carried item: the commit its pass was recorded on */
|
|
40
|
+
commit?: string;
|
|
41
|
+
/** the milestone the verifier checks, when gate_check named it: the digest lists only that milestone's items */
|
|
42
|
+
milestone?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `<data>/repos/<repo key>/gates.jsonl`, beside the repo's knowledge.md. */
|
|
46
|
+
export const gatesFile = (toplevel: string): string => join(repoDir(toplevel), "gates.jsonl");
|
|
47
|
+
const stepsFile = (run: Run): string => join(run.dir, "verifier.jsonl");
|
|
48
|
+
|
|
49
|
+
/** Repo-relative paths, `.` for the whole repo; anything that leaves the repo is refused. */
|
|
50
|
+
function cleanPaths(paths: string[]): string[] {
|
|
51
|
+
try {
|
|
52
|
+
return [...new Set(paths.map((p) => (p.trim() === "." ? "." : normalizeOwned(p))))].sort();
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (isCatherdError(e))
|
|
55
|
+
throw new CatherdError("E_INPUT_INVALID", e.message.replace("owned path", "gate path"), {
|
|
56
|
+
fix: "pass repo-relative paths, like src/ or package.json, or . for the whole repo",
|
|
57
|
+
});
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sha = (b: string | Uint8Array) => new Bun.CryptoHasher("sha256").update(b).digest("hex");
|
|
63
|
+
|
|
64
|
+
const tooMany = (p: string) =>
|
|
65
|
+
new CatherdError("E_INPUT_INVALID", `gate path ${p} holds more than ${GATE_WALK_MAX} files to hash`, {
|
|
66
|
+
fix: "name narrower paths: the files or directories under it that the gate item reads",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** How many files one gate path's hash has read so far, against GATE_WALK_MAX, and the realpaths it walked. */
|
|
70
|
+
interface Budget {
|
|
71
|
+
path: string;
|
|
72
|
+
files: number;
|
|
73
|
+
seen: Set<string>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const count = (b: Budget): void => {
|
|
77
|
+
if (++b.files > GATE_WALK_MAX) throw tooMany(b.path);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What a symlink points at, by content only (never its path, which may be outside the repo): a file by its
|
|
82
|
+
* type, exec bit and bytes; a directory walked, names relative to it; "dangling" when nothing is there; and
|
|
83
|
+
* "cycle" for a realpath this hash already walked.
|
|
84
|
+
*/
|
|
85
|
+
async function targetEntry(link: string, b: Budget): Promise<string> {
|
|
86
|
+
let real: string;
|
|
87
|
+
try {
|
|
88
|
+
real = realpathSync(link);
|
|
89
|
+
} catch {
|
|
90
|
+
return "dangling";
|
|
91
|
+
}
|
|
92
|
+
if (b.seen.has(real)) return "cycle";
|
|
93
|
+
b.seen.add(real);
|
|
94
|
+
const s = statSync(real);
|
|
95
|
+
if (s.isFile()) return `${s.mode & 0o111 ? "exec" : "file"} ${sha(await Bun.file(real).bytes())}`;
|
|
96
|
+
if (!s.isDirectory()) return "other";
|
|
97
|
+
const h = new Bun.CryptoHasher("sha256");
|
|
98
|
+
const visit = async (rel: string): Promise<void> => {
|
|
99
|
+
for (const name of readdirSync(join(real, rel)).sort()) {
|
|
100
|
+
const r = rel ? `${rel}/${name}` : name;
|
|
101
|
+
let e: Stats;
|
|
102
|
+
try {
|
|
103
|
+
e = lstatSync(join(real, r));
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (e.isDirectory()) {
|
|
108
|
+
await visit(r);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
count(b);
|
|
112
|
+
h.update(`${r}=${await diskEntry(join(real, r), b)}\n`);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
await visit("");
|
|
116
|
+
return `dir ${h.digest("hex")}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One path on disk as git would store it: its type and exec bit as well as its bytes, so a lost exec bit or a
|
|
121
|
+
* file turned symlink is new content; a symlink by its link text and by what it points at (targetEntry), so
|
|
122
|
+
* editing a link's target is new content too.
|
|
123
|
+
*/
|
|
124
|
+
async function diskEntry(file: string, b: Budget): Promise<string> {
|
|
125
|
+
let s: Stats;
|
|
126
|
+
try {
|
|
127
|
+
s = lstatSync(file);
|
|
128
|
+
} catch {
|
|
129
|
+
return "gone";
|
|
130
|
+
}
|
|
131
|
+
if (s.isSymbolicLink()) return `link ${sha(readlinkSync(file))} -> ${await targetEntry(file, b)}`;
|
|
132
|
+
if (!s.isFile()) return s.isDirectory() ? "dir" : "other";
|
|
133
|
+
return `${s.mode & 0o111 ? "exec" : "file"} ${sha(await Bun.file(file).bytes())}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One dirty path's entry, with its own budget and cycle guard. */
|
|
137
|
+
const dirtyEntry = (file: string, path: string): Promise<string> =>
|
|
138
|
+
diskEntry(file, { path, files: 0, seen: new Set() });
|
|
139
|
+
|
|
140
|
+
/** Whether `file` is a symlink on disk. */
|
|
141
|
+
const isLink = (file: string): boolean => {
|
|
142
|
+
try {
|
|
143
|
+
return lstatSync(file).isSymbolicLink();
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Whether git ignores `p` (a tracked file never is). */
|
|
150
|
+
async function ignored(repo: string, p: string): Promise<boolean> {
|
|
151
|
+
return (await git(repo, ["check-ignore", "-q", "--", p.replace(/\/$/, "")])).kind === "ok";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The most files a gate path's on-disk walk hashes. */
|
|
155
|
+
export const GATE_WALK_MAX = 10_000;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The files under `p` on disk (itself when it is not a directory), repo-relative and sorted; a symlink is
|
|
159
|
+
* an entry, not followed. More than GATE_WALK_MAX is refused, not hashed in part.
|
|
160
|
+
*/
|
|
161
|
+
function walk(repo: string, p: string): string[] {
|
|
162
|
+
const out: string[] = [];
|
|
163
|
+
const visit = (rel: string): void => {
|
|
164
|
+
let s: Stats;
|
|
165
|
+
try {
|
|
166
|
+
s = lstatSync(join(repo, rel));
|
|
167
|
+
} catch {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!s.isDirectory()) {
|
|
171
|
+
out.push(rel);
|
|
172
|
+
if (out.length > GATE_WALK_MAX) throw tooMany(p);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const name of readdirSync(join(repo, rel))) visit(`${rel}/${name}`);
|
|
176
|
+
};
|
|
177
|
+
visit(p.replace(/\/$/, ""));
|
|
178
|
+
return out.sort();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The content hash of `paths`: each one's tree entry at HEAD (mode, type and object id), plus the content of every
|
|
183
|
+
* uncommitted change under them, so a verifier checking a tree not yet committed gets a hash of what it ran.
|
|
184
|
+
* A path git ignores, or one not at HEAD, is also hashed file by file from disk; ignored files under `.` or
|
|
185
|
+
* under a tracked directory are not, so an ignored input is covered only when it is named.
|
|
186
|
+
*/
|
|
187
|
+
async function contentHash(repo: string, paths: string[]): Promise<string> {
|
|
188
|
+
const h = new Bun.CryptoHasher("sha256");
|
|
189
|
+
for (const p of paths) {
|
|
190
|
+
// the tree entry, mode and type with the object id (`100755 blob <sha>`), so a committed chmod -x is new
|
|
191
|
+
// content; a directory's tree id already covers its children's modes
|
|
192
|
+
const entry = await git(
|
|
193
|
+
repo,
|
|
194
|
+
p === "."
|
|
195
|
+
? ["rev-parse", "HEAD^{tree}"]
|
|
196
|
+
: ["ls-tree", "--full-tree", "HEAD", "--", p.replace(/\/$/, "")],
|
|
197
|
+
);
|
|
198
|
+
const at = entry.kind === "ok" ? entry.out.trim() : "";
|
|
199
|
+
// a mistyped path (or a glob) would hash as a constant and carry a pass forever
|
|
200
|
+
if (!at && !existsSync(join(repo, p)))
|
|
201
|
+
throw new CatherdError(
|
|
202
|
+
"E_INPUT_INVALID",
|
|
203
|
+
`gate path ${p} exists neither at HEAD nor in the working tree`,
|
|
204
|
+
{
|
|
205
|
+
fix: "check the spelling: pass repo-relative files or directories that exist, like src/ or package.json (no globs)",
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
h.update(`${p}=${at || "missing"}\n`);
|
|
209
|
+
// git status leaves ignored files out, so an ignored path (.env, a build output) or one not at HEAD is
|
|
210
|
+
// hashed by what is on disk; "." keeps to HEAD and the not-ignored status. A symlink's HEAD entry is only
|
|
211
|
+
// its link text, so a named symlink is hashed from disk too, by what it points at
|
|
212
|
+
if (p !== "." && (!at || isLink(join(repo, p)) || (await ignored(repo, p)))) {
|
|
213
|
+
// one budget per gate path: its walk and every symlink target it follows count against GATE_WALK_MAX
|
|
214
|
+
const budget: Budget = { path: p, files: 0, seen: new Set() };
|
|
215
|
+
for (const f of walk(repo, p)) h.update(`disk ${f}=${await diskEntry(join(repo, f), budget)}\n`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const dirty = Object.keys(await statusSnapshot(repo))
|
|
219
|
+
.filter((f) => paths.includes(".") || overlaps([f], paths).length > 0)
|
|
220
|
+
.sort();
|
|
221
|
+
for (const f of dirty) h.update(`dirty ${f}=${await dirtyEntry(join(repo, f), f)}\n`);
|
|
222
|
+
return h.digest("hex");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const readPasses = (repo: string): GatePass[] =>
|
|
226
|
+
readJsonl<unknown>(gatesFile(repo)).rows.flatMap((row) => {
|
|
227
|
+
const r = GatePassSchema.safeParse(row);
|
|
228
|
+
return r.success ? [r.data] : [];
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
function recordStep(run: Run, step: VerifierStep): void {
|
|
232
|
+
const file = stepsFile(run);
|
|
233
|
+
ensureJsonlHeader(file, "verifier");
|
|
234
|
+
appendJsonl(file, step);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The verifier's latest step in this run, for `status` and `peek`; null before its first gate_check. */
|
|
238
|
+
export function latestVerifierStep(run: Run): VerifierStep | null {
|
|
239
|
+
return (readJsonl<VerifierStep>(stepsFile(run))
|
|
240
|
+
.rows.filter((r) => typeof r?.item === "string")
|
|
241
|
+
.at(-1) ?? null) as VerifierStep | null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `gate_check`: carried when this repo has a pass with the same command on the same content of `paths`;
|
|
246
|
+
* records "verifier step: <item>" either way.
|
|
247
|
+
*/
|
|
248
|
+
export async function gateCheck(
|
|
249
|
+
deps: Deps,
|
|
250
|
+
i: { run: string; item: string; command: string; paths: string[]; milestone?: string },
|
|
251
|
+
): Promise<{ carried: true; passedAt: string; commit: string } | { carried: false }> {
|
|
252
|
+
const run = findRun(i.run);
|
|
253
|
+
const paths = cleanPaths(i.paths);
|
|
254
|
+
const hash = await contentHash(run.meta.repo, paths);
|
|
255
|
+
const pass = readPasses(run.meta.repo).findLast((p) => p.command === i.command && p.hash === hash);
|
|
256
|
+
recordStep(run, {
|
|
257
|
+
at: new Date(deps.now()).toISOString(),
|
|
258
|
+
item: i.item,
|
|
259
|
+
carried: pass !== undefined,
|
|
260
|
+
...(pass ? { commit: pass.commit } : {}),
|
|
261
|
+
...(i.milestone ? { milestone: i.milestone } : {}),
|
|
262
|
+
});
|
|
263
|
+
return pass ? { carried: true, passedAt: pass.at, commit: pass.commit } : { carried: false };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** `gate_pass`: records that `command` passed on the current content of `paths`, with its evidence. */
|
|
267
|
+
export async function gatePass(
|
|
268
|
+
deps: Deps,
|
|
269
|
+
i: { run: string; item: string; command: string; paths: string[]; evidence: string },
|
|
270
|
+
): Promise<{ recorded: true; hash: string; commit: string }> {
|
|
271
|
+
const run = findRun(i.run);
|
|
272
|
+
const paths = cleanPaths(i.paths);
|
|
273
|
+
const hash = await contentHash(run.meta.repo, paths);
|
|
274
|
+
const commit = (await gitHead(run.meta.repo)) ?? "none";
|
|
275
|
+
const file = gatesFile(run.meta.repo);
|
|
276
|
+
ensureJsonlHeader(file, "gates");
|
|
277
|
+
appendJsonl(file, {
|
|
278
|
+
at: new Date(deps.now()).toISOString(),
|
|
279
|
+
run: run.id,
|
|
280
|
+
item: i.item,
|
|
281
|
+
command: i.command,
|
|
282
|
+
paths,
|
|
283
|
+
hash,
|
|
284
|
+
commit,
|
|
285
|
+
evidence: i.evidence,
|
|
286
|
+
} satisfies GatePass);
|
|
287
|
+
return { recorded: true, hash, commit };
|
|
288
|
+
}
|