catherd-cli 0.2.0 → 1.0.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.
Files changed (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -25
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -69
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -0,0 +1,359 @@
1
+ import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
2
+ import { relative } from "node:path";
3
+ import type { BackendAdapter, FinishedRun, Outcome, Spent } from "../adapters/backend.ts";
4
+ import { adapterFor } from "../adapters/registry.ts";
5
+ import "../adapters/all.ts";
6
+ import { changedPaths, type Snapshot, splitChanges } from "../domain/changes.ts";
7
+ import { isCatherdError } from "../domain/errors.ts";
8
+ import { parseRung } from "../domain/ids.ts";
9
+ import {
10
+ type ExitInfo,
11
+ parseReplyStatus,
12
+ type RunRecord,
13
+ THREAD_HEAVY_INPUT,
14
+ ZERO_TOKENS,
15
+ } from "../domain/record.ts";
16
+ import { dispatchPaths, readClaimant, readExit, tryClaim } from "../infra/dispatch-dir.ts";
17
+ import { isAlive } from "../infra/proc.ts";
18
+ import { withFileLock } from "../infra/filelock.ts";
19
+ import { gitLimits, statusSnapshot } from "../infra/git.ts";
20
+ import {
21
+ appendJsonl,
22
+ ensureJsonlHeader,
23
+ makePrivate,
24
+ nonBlankLines,
25
+ writeTextAtomic,
26
+ } from "../infra/store.ts";
27
+ import { type Dispatch, dispatchState, listDispatches, readProc } from "./dispatches.ts";
28
+ import { appendRecord, readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
29
+
30
+ const text = (file: string): string => {
31
+ try {
32
+ return readFileSync(file, "utf8");
33
+ } catch {
34
+ return "";
35
+ }
36
+ };
37
+
38
+ function claimAgeMs(dir: string): number {
39
+ try {
40
+ return Date.now() - statSync(dispatchPaths(dir).claim).mtimeMs;
41
+ } catch {
42
+ return 0;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Whether another finalizer's claim may be taken over: its claimant is dead, or the claim is older than
48
+ * the longest a live claimant can take: every bounded step of `compute` (the adapter's settle, the one
49
+ * `git status` snapshot, each at its own timeout) plus the margin for its unbounded file work. A claim
50
+ * that names no one (an older build's) is judged by its age alone.
51
+ */
52
+ function claimStale(dir: string): boolean {
53
+ const who = readClaimant(dir);
54
+ if (who && !isAlive(who.pid, who.startTime)) return true;
55
+ return claimAgeMs(dir) > settleLimits.timeoutMs + gitLimits.timeoutMs + settleLimits.claimMarginMs;
56
+ }
57
+
58
+ /** The owned paths of the run's other dispatches whose lifetime overlapped [start, end]. */
59
+ function othersOwns(run: Run, self: Dispatch, start: number, end: number): string[] {
60
+ const ended = new Map(readRecords(run).records.map((r) => [r.dispatchId, Date.parse(r.endedAt)]));
61
+ return listDispatches(run)
62
+ .filter((d) => d.admit.dispatchId !== self.admit.dispatchId)
63
+ .filter((d) => {
64
+ const from = Date.parse(d.admit.admittedAt);
65
+ const to = ended.get(d.admit.dispatchId) ?? (Date.parse(readExit(d.dir)?.endedAt ?? "") || Date.now());
66
+ return from <= end && to >= start;
67
+ })
68
+ .flatMap((d) => d.admit.owns);
69
+ }
70
+
71
+ /** The tree after the run, or null when git cannot say: the record is still written, its changes unknown. */
72
+ async function snapshotOrNull(repo: string): Promise<Snapshot | null> {
73
+ try {
74
+ return await statusSnapshot(repo);
75
+ } catch (e) {
76
+ if (isCatherdError(e) && e.code === "E_IO_UNEXPECTED") return null;
77
+ throw e;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * How long a backend may take to settle a finished session before its stream's own figures stand, and
83
+ * how much longer than that and the git snapshot's timeout (gitLimits) a finalizer's claim may live (its file
84
+ * work and the append) before a second finalizer takes it over.
85
+ */
86
+ export const settleLimits = { timeoutMs: 20_000, claimMarginMs: 10_000 };
87
+
88
+ /**
89
+ * What earlier records on `thread` already counted: a resumed session's totals repeat them. Every run's,
90
+ * since a role may resume a thread an earlier catherd run started.
91
+ */
92
+ function priorOn(run: Run, backend: string, thread: string, self: string): Spent {
93
+ const earlier = recordsOnThread(run, backend, thread).filter((r) => r.dispatchId !== self);
94
+ return {
95
+ tokens: {
96
+ input: earlier.reduce((n, r) => n + r.tokens.input, 0),
97
+ cached: earlier.reduce((n, r) => n + r.tokens.cached, 0),
98
+ output: earlier.reduce((n, r) => n + r.tokens.output, 0),
99
+ },
100
+ costUsd: earlier.reduce((n, r) => n + (r.costUsd ?? 0), 0),
101
+ };
102
+ }
103
+
104
+ /** The adapter's settled outcome, or `o` unchanged when it has none, throws, or is slower than the limit. */
105
+ export async function settled(
106
+ adapter: BackendAdapter,
107
+ o: Outcome,
108
+ finished: FinishedRun,
109
+ prior: () => Spent,
110
+ ): Promise<Outcome> {
111
+ if (!adapter.settle || o.thread === null) return o;
112
+ let timer: ReturnType<typeof setTimeout> | undefined;
113
+ try {
114
+ const late = new Promise<Outcome>((resolve) => {
115
+ timer = setTimeout(() => resolve(o), settleLimits.timeoutMs);
116
+ });
117
+ return await Promise.race([adapter.settle(o, finished, prior()), late]);
118
+ } catch {
119
+ return o;
120
+ } finally {
121
+ clearTimeout(timer);
122
+ }
123
+ }
124
+
125
+ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
126
+ const a = d.admit;
127
+ const p = dispatchPaths(d.dir);
128
+ // no exit.json: its supervisor died. A cancel asked for before that ended it; anything else is lost.
129
+ const exit: ExitInfo = readExit(d.dir) ?? {
130
+ code: null,
131
+ signal: null,
132
+ reason: existsSync(p.cancel) ? "cancelled" : "lost",
133
+ endedAt: new Date().toISOString(),
134
+ };
135
+ const startedAt = readProc(d.dir)?.startedAt ?? a.admittedAt;
136
+ const reply = text(p.reply);
137
+ const finished: FinishedRun = {
138
+ request: {
139
+ rung: parseRung(a.rung),
140
+ access: a.access,
141
+ thread: a.thread,
142
+ isolated: a.isolated,
143
+ repo: a.repo,
144
+ briefPath: p.brief,
145
+ replyPath: p.reply,
146
+ dispatchDir: d.dir,
147
+ },
148
+ eventLines: nonBlankLines(p.events),
149
+ reply,
150
+ stderr: text(p.stderr),
151
+ exit,
152
+ startedAtMs: Date.parse(startedAt),
153
+ };
154
+ const adapter = adapterFor(a.backend);
155
+ const first: Outcome = adapter
156
+ ? adapter.finalize(finished)
157
+ : {
158
+ status: "failed",
159
+ thread: a.thread,
160
+ tokens: { ...ZERO_TOKENS },
161
+ costUsd: null,
162
+ images: [],
163
+ error: { code: "E_BACKEND_MISSING", message: `catherd has no ${a.backend} adapter` },
164
+ };
165
+ const o = adapter
166
+ ? await settled(adapter, first, finished, () => priorOn(run, a.backend, first.thread ?? "", a.dispatchId))
167
+ : first;
168
+ // A CLI that streams its reply (claude, opencode) leaves reply.md to catherd.
169
+ if (o.reply !== undefined && o.reply !== reply) writeTextAtomic(p.reply, o.reply);
170
+ // one the CLI wrote itself (codex) has the CLI's mode
171
+ else makePrivate(p.reply);
172
+ const replyText = o.reply ?? reply;
173
+ const start = Date.parse(startedAt);
174
+ const end = Date.parse(exit.endedAt);
175
+ const after = await snapshotOrNull(a.repo);
176
+ const { changedOwned, violations } = after
177
+ ? splitChanges(
178
+ changedPaths(a.before, after),
179
+ a.owns,
180
+ // from admission, when the before-snapshot was taken, not from the worker's start
181
+ othersOwns(run, d, Date.parse(a.admittedAt), end),
182
+ a.lane !== null || a.access === "read-only",
183
+ )
184
+ : { changedOwned: [], violations: [] };
185
+ const reported = parseReplyStatus(replyText);
186
+ return {
187
+ schema: 1,
188
+ runId: run.id,
189
+ dispatchId: a.dispatchId,
190
+ name: a.name,
191
+ role: a.role,
192
+ lane: a.lane,
193
+ backend: a.backend,
194
+ rung: a.rung,
195
+ attempt: a.attempt,
196
+ failoverFrom: a.failoverFrom,
197
+ thread: o.thread,
198
+ status: o.status,
199
+ startedAt,
200
+ endedAt: exit.endedAt,
201
+ secs: Math.max(0, Math.round((end - start) / 1000)),
202
+ exitCode: exit.code,
203
+ signal: exit.signal,
204
+ cliVersion: a.cliVersion,
205
+ tokens: o.tokens,
206
+ costUsd: o.costUsd,
207
+ changedOwned,
208
+ violations,
209
+ ...(after ? {} : { gitUnavailable: true }),
210
+ replyStatus: reported.status,
211
+ replyWhy: reported.why,
212
+ threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
213
+ access: a.access,
214
+ isolated: a.isolated,
215
+ images: o.images,
216
+ error: o.error,
217
+ replyPath: relative(run.dir, p.reply),
218
+ };
219
+ }
220
+
221
+ /**
222
+ * A fresh thread's first-turn input, for the harness-cost line of runs_summary: its first model request's
223
+ * input, from a backend whose stream reports usage per request. Codex reports it per exec: no row.
224
+ */
225
+ function recordHarness(run: Run, d: Dispatch, r: RunRecord): void {
226
+ const a = adapterFor(d.admit.backend);
227
+ if (d.admit.thread !== null || !a) return;
228
+ for (const line of nonBlankLines(dispatchPaths(d.dir).events)) {
229
+ let input;
230
+ try {
231
+ input = a.parse(line).requestInput;
232
+ } catch {
233
+ continue;
234
+ }
235
+ if (input === undefined) continue;
236
+ const file = runPaths(run.dir).harness;
237
+ ensureJsonlHeader(file, "harness");
238
+ appendJsonl(file, {
239
+ at: r.endedAt,
240
+ name: r.name,
241
+ backend: r.backend,
242
+ isolated: r.isolated,
243
+ firstTurnInput: input,
244
+ });
245
+ return;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Spec §3.3: the first finalizer claims the dispatch and writes its record; any other waits for that
251
+ * record and returns it. If the claimer died before appending, the late finalizer appends instead;
252
+ * appendRecord's per-dispatch dedupe keeps that to one record either way (audit C2). A claimer that throws
253
+ * releases its claim.
254
+ *
255
+ * In this process, a second finalizer (a `wait` beside the dispatch's watcher) joins the first one's
256
+ * promise. Across processes it waits for the claimant's record, and takes over only once the claim is
257
+ * stale (claimant dead, or past its settle window): never while a live claimant may still be settling.
258
+ */
259
+ export function finalizeDispatch(run: Run, d: Dispatch): Promise<RunRecord> {
260
+ const id = d.admit.dispatchId;
261
+ const joined = inFlight.get(id);
262
+ if (joined) return joined;
263
+ const p = finalizeOnce(run, d).finally(() => inFlight.delete(id));
264
+ inFlight.set(id, p);
265
+ return p;
266
+ }
267
+
268
+ /** This process's finalizes in flight, by dispatch id. */
269
+ const inFlight = new Map<string, Promise<RunRecord>>();
270
+
271
+ const recordOf = (run: Run, id: string): RunRecord | undefined =>
272
+ readRecords(run).records.find((r) => r.dispatchId === id);
273
+
274
+ /** Test seam: runs after a finalizer judged a claim stale, before it takes the lock to take it over. */
275
+ export const claimSeams = { beforeTakeover: async (_dir: string): Promise<void> => {} };
276
+
277
+ /**
278
+ * Takes over a stale claim, serialized by a lock on the claim: under it, reads the claim again, removes it
279
+ * only if it is still stale, and claims it. True for the one caller that now holds the claim; a caller that
280
+ * finds it live (another took it over, or its claimant woke) or loses the claim to a fresh claimant gets
281
+ * false and goes back to waiting for the record. A claim is removed only here and by its own claimant, and
282
+ * here only right before claiming it, so there is never a second live computer, and never none for long:
283
+ * a claim that vanished is claimed by the next finalizer's poll.
284
+ */
285
+ export async function takeOverStaleClaim(dir: string): Promise<boolean> {
286
+ const claim = dispatchPaths(dir).claim;
287
+ return withFileLock(claim, () => {
288
+ if (existsSync(claim)) {
289
+ if (!claimStale(dir)) return false;
290
+ rmSync(claim, { force: true });
291
+ }
292
+ return tryClaim(dir);
293
+ });
294
+ }
295
+
296
+ async function finalizeOnce(run: Run, d: Dispatch): Promise<RunRecord> {
297
+ for (;;) {
298
+ const existing = recordOf(run, d.admit.dispatchId);
299
+ if (existing) return existing;
300
+ // a claim released by a claimer that threw is gone: this claims it
301
+ if (tryClaim(d.dir)) break;
302
+ if (existsSync(dispatchPaths(d.dir).claim) && claimStale(d.dir)) {
303
+ await claimSeams.beforeTakeover(d.dir);
304
+ if (await takeOverStaleClaim(d.dir)) break;
305
+ continue;
306
+ }
307
+ await Bun.sleep(50);
308
+ }
309
+ let saved: RunRecord;
310
+ let mine: RunRecord;
311
+ try {
312
+ mine = await compute(run, d);
313
+ saved = await appendRecord(run, mine);
314
+ } catch (e) {
315
+ // no record: let the next finalizer try at once rather than wait on this claim; ours only, since a
316
+ // claim held past its window may have been taken over
317
+ if (readClaimant(d.dir)?.pid === process.pid) rmSync(dispatchPaths(d.dir).claim, { force: true });
318
+ throw e;
319
+ }
320
+ if (saved === mine) recordHarness(run, d, mine);
321
+ return saved;
322
+ }
323
+
324
+ /** The last event of a running dispatch worth showing in a progress line, if any. */
325
+ export function lastEvent(d: Dispatch): string | null {
326
+ const a = adapterFor(d.admit.backend);
327
+ const line = nonBlankLines(dispatchPaths(d.dir).events).at(-1);
328
+ if (!a || !line) return null;
329
+ try {
330
+ return a.parse(line).lastEvent ?? null;
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ /** Waits until the dispatch finishes, calling `onTick` every `tickMs`; a throwing onTick never ends the wait. */
337
+ export async function waitForFinish(
338
+ d: Dispatch,
339
+ o: {
340
+ pollMs: number;
341
+ tickMs: number;
342
+ now: () => number;
343
+ onTick?: (secs: number, lastEvent: string | null) => void;
344
+ },
345
+ ): Promise<void> {
346
+ const started = Date.now();
347
+ let ticked = started;
348
+ while (dispatchState(d, o.now()) !== "finished") {
349
+ await Bun.sleep(o.pollMs);
350
+ if (o.onTick && Date.now() - ticked >= o.tickMs) {
351
+ ticked = Date.now();
352
+ try {
353
+ o.onTick(Math.round((ticked - started) / 1000), lastEvent(d));
354
+ } catch {
355
+ // a progress report must never stop the wait
356
+ }
357
+ }
358
+ }
359
+ }
@@ -0,0 +1,183 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { CatherdError, isCatherdError } from "../domain/errors.ts";
5
+ import type { RouteSource } from "../domain/route.ts";
6
+ import {
7
+ canonicalJson,
8
+ type JevAnswers,
9
+ type JevFile,
10
+ JevFileSchema,
11
+ parseReply,
12
+ questionSetId,
13
+ requestKey,
14
+ type SetName,
15
+ sha256,
16
+ } from "../domain/jev.ts";
17
+ import { assetPath } from "../infra/assets.ts";
18
+ import { type JevTransport, jevRequest } from "../infra/jev-client.ts";
19
+ import { addSecret, log } from "../infra/log.ts";
20
+ import { configDir } from "../infra/paths.ts";
21
+ import { appendJsonl, ensureJsonlHeader, readJsonl, readVersioned, writeJsonAtomic } from "../infra/store.ts";
22
+
23
+ let file: JevFile | null = null;
24
+ /** catalog/jev.json: the question sets and their rules (spec §5.5). */
25
+ export const jevQuestions = (): JevFile =>
26
+ (file ??= readVersioned(assetPath("catalog/jev.json"), JevFileSchema, 1));
27
+
28
+ export const credentialsPath = (): string => join(configDir(), "credentials.json");
29
+ const CredentialsSchema = z.looseObject({
30
+ schema: z.literal(1).default(1),
31
+ typesafeApiKey: z.string().optional(),
32
+ });
33
+
34
+ /** Registers the saved Jev key with the redactor, for a process that reads logs without ever calling Jev. */
35
+ export function registerSavedSecrets(): void {
36
+ addSecret(jevKey());
37
+ }
38
+
39
+ /** How to repair a credentials file catherd cannot read: `init` alone refuses to overwrite it. */
40
+ const credentialsFix = (): string =>
41
+ `delete ${credentialsPath()} and run catherd init, or write it as {"schema": 1, "typesafeApiKey": "<your key>"}`;
42
+ const readCredentials = () =>
43
+ readVersioned(credentialsPath(), CredentialsSchema, 1, { fix: credentialsFix() });
44
+
45
+ /** The key saved in credentials.json, or why that file cannot be read (unparsable, newer schema). */
46
+ export function savedJevKey(): { key: string | null; problem: CatherdError | null } {
47
+ if (!existsSync(credentialsPath())) return { key: null, problem: null };
48
+ try {
49
+ return { key: readCredentials().typesafeApiKey?.trim() || null, problem: null };
50
+ } catch (e) {
51
+ return {
52
+ key: null,
53
+ problem: isCatherdError(e)
54
+ ? e
55
+ : new CatherdError("E_CONFIG_INVALID", String(e), { fix: credentialsFix() }),
56
+ };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Spec §5.5: `TYPESAFE_API_KEY`, else `<config>/credentials.json`; null when neither has one. Jev is
62
+ * optional (D1), so a credentials file that cannot be read means no key here; it is logged, and
63
+ * `catherd doctor` says why.
64
+ */
65
+ export function jevKey(): string | null {
66
+ const env = process.env.TYPESAFE_API_KEY?.trim();
67
+ if (env) return env;
68
+ const { key, problem } = savedJevKey();
69
+ if (problem) log("warn", "jev", { error: `no key: ${problem.message}` });
70
+ return key;
71
+ }
72
+
73
+ /**
74
+ * Keeps any other credential, and the file at mode 600 (spec §10.4). A missing file starts empty; an
75
+ * unreadable or newer-schema one is refused (it throws) rather than overwritten.
76
+ */
77
+ export function saveJevKey(key: string): void {
78
+ const cur: z.infer<typeof CredentialsSchema> = existsSync(credentialsPath())
79
+ ? readCredentials()
80
+ : { schema: 1 };
81
+ writeJsonAtomic(credentialsPath(), { ...cur, schema: 1, typesafeApiKey: key.trim() }, { mode: 0o600 });
82
+ }
83
+
84
+ /** A key works when Jev lists its models: no inference, and no dependence on a question set. */
85
+ export async function testJevKey(key: string, o: JevTransport = {}): Promise<boolean> {
86
+ return (await jevRequest("GET", "/models", key, undefined, o)).ok;
87
+ }
88
+
89
+ export interface JevOpts extends JevTransport {
90
+ /** a key to use instead of jevKey(); null means none */
91
+ key?: string | null;
92
+ }
93
+
94
+ /** One jev.jsonl row (spec §5.5): never the state, only its hash. */
95
+ export interface JevRow {
96
+ at: string;
97
+ call: SetName;
98
+ lane: string | null;
99
+ questionSet: string;
100
+ key: string;
101
+ stateHash: string;
102
+ model: string | null;
103
+ requestId: string | null;
104
+ usage: { input_tokens: number; output_tokens: number } | null;
105
+ latencyMs: number | null;
106
+ attempts: number;
107
+ cached: boolean;
108
+ answers: JevAnswers | null;
109
+ derived: Record<string, unknown> | null;
110
+ used: string;
111
+ source: RouteSource;
112
+ why: string;
113
+ }
114
+
115
+ export interface Asked {
116
+ answers: JevAnswers | null;
117
+ /** why Jev gave no answers ("no key", "http 401", …), or a note on answers from an unpinned model */
118
+ why: string | null;
119
+ meta: Omit<JevRow, "at" | "answers" | "derived" | "used" | "source" | "why" | "call" | "lane">;
120
+ }
121
+
122
+ const jevLog = (runDir: string) => join(runDir, "jev.jsonl");
123
+
124
+ export function logJev(runDir: string, row: Omit<JevRow, "at">): void {
125
+ const f = jevLog(runDir);
126
+ ensureJsonlHeader(f, "jev");
127
+ appendJsonl(f, { at: new Date().toISOString(), ...row });
128
+ }
129
+
130
+ /**
131
+ * Asks one question set about `state`. A request this run already had answered (same model, state and
132
+ * questions) is answered from jev.jsonl, so a re-routed lane gets the same decision.
133
+ */
134
+ export async function askJev(runDir: string, set: SetName, state: unknown, o: JevOpts = {}): Promise<Asked> {
135
+ const f = jevQuestions();
136
+ const questions = f.sets[set].questions;
137
+ const key = requestKey(f.model, state, questions);
138
+ const meta: Asked["meta"] = {
139
+ questionSet: questionSetId(f, set),
140
+ key,
141
+ stateHash: sha256(canonicalJson(state)),
142
+ model: null,
143
+ requestId: null,
144
+ usage: null,
145
+ latencyMs: null,
146
+ attempts: 0,
147
+ cached: false,
148
+ };
149
+ // a logged answer is checked like a fresh one: a row edited by hand, or cut short, is asked again
150
+ for (const hit of readJsonl<Partial<JevRow>>(jevLog(runDir)).rows) {
151
+ if (hit.key !== key || !hit.answers) continue;
152
+ const cached = parseReply(questions, { model: hit.model ?? "", answers: hit.answers });
153
+ if (!cached.ok) continue;
154
+ const model = hit.model ?? null;
155
+ return { answers: cached.answers, why: drift(model, f.model), meta: { ...meta, model, cached: true } };
156
+ }
157
+ const apiKey = o.key === undefined ? jevKey() : o.key;
158
+ if (!apiKey) return { answers: null, why: "no key", meta };
159
+ addSecret(apiKey);
160
+ const res = await jevRequest("POST", "/systemone", apiKey, { model: f.model, state, questions }, o);
161
+ log(res.ok ? "info" : "warn", "jev", {
162
+ set,
163
+ questionSet: meta.questionSet,
164
+ stateHash: meta.stateHash,
165
+ requestId: res.ok ? res.requestId : null,
166
+ latencyMs: res.latencyMs,
167
+ attempts: res.attempts,
168
+ ...(res.ok ? {} : { error: res.error }),
169
+ });
170
+ meta.latencyMs = res.latencyMs;
171
+ meta.attempts = res.attempts;
172
+ if (!res.ok) return { answers: null, why: res.error, meta };
173
+ meta.requestId = res.requestId;
174
+ const parsed = parseReply(questions, res.body);
175
+ if (!parsed.ok) return { answers: null, why: parsed.error, meta };
176
+ meta.model = parsed.model;
177
+ meta.usage = parsed.usage;
178
+ return { answers: parsed.answers, why: drift(parsed.model, f.model), meta };
179
+ }
180
+
181
+ /** The note on answers from a model other than the pinned one, or null. */
182
+ const drift = (model: string | null, pinned: string): string | null =>
183
+ model === null || model === pinned ? null : `answered by ${model}, not the pinned ${pinned}`;