catherd-cli 0.2.1 → 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.
- package/README.md +114 -30
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +227 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +316 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +58 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -26
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +33 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +133 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +196 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
- /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}`;
|