catherd-cli 0.2.1 → 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 +141 -33
- 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 +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -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 +282 -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 +333 -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 +60 -0
- package/src/domain/failover.ts +119 -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 +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- 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 +49 -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 +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -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 +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -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 +160 -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 +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -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 +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -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 +197 -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/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -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/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -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 +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- 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 +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -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/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -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
|
@@ -0,0 +1,377 @@
|
|
|
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 { tail } from "./run-debug.ts";
|
|
29
|
+
import { appendRecord, readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
|
|
30
|
+
|
|
31
|
+
const text = (file: string): string => {
|
|
32
|
+
try {
|
|
33
|
+
return readFileSync(file, "utf8");
|
|
34
|
+
} catch {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function claimAgeMs(dir: string): number {
|
|
40
|
+
try {
|
|
41
|
+
return Date.now() - statSync(dispatchPaths(dir).claim).mtimeMs;
|
|
42
|
+
} catch {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether another finalizer's claim may be taken over: its claimant is dead, or the claim is older than
|
|
49
|
+
* the longest a live claimant can take: every bounded step of `compute` (the adapter's settle, the one
|
|
50
|
+
* `git status` snapshot, each at its own timeout) plus the margin for its unbounded file work. A claim
|
|
51
|
+
* that names no one (an older build's) is judged by its age alone.
|
|
52
|
+
*/
|
|
53
|
+
function claimStale(dir: string): boolean {
|
|
54
|
+
const who = readClaimant(dir);
|
|
55
|
+
if (who && !isAlive(who.pid, who.startTime)) return true;
|
|
56
|
+
return claimAgeMs(dir) > settleLimits.timeoutMs + gitLimits.timeoutMs + settleLimits.claimMarginMs;
|
|
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
|
+
|
|
68
|
+
/** The owned paths of the run's other dispatches whose lifetime overlapped [start, end]. */
|
|
69
|
+
function othersOwns(run: Run, self: Dispatch, start: number, end: number): string[] {
|
|
70
|
+
const ended = new Map(readRecords(run).records.map((r) => [r.dispatchId, Date.parse(r.endedAt)]));
|
|
71
|
+
return listDispatches(run)
|
|
72
|
+
.filter((d) => d.admit.dispatchId !== self.admit.dispatchId)
|
|
73
|
+
.filter((d) => {
|
|
74
|
+
const from = Date.parse(d.admit.admittedAt);
|
|
75
|
+
const to = ended.get(d.admit.dispatchId) ?? (Date.parse(readExit(d.dir)?.endedAt ?? "") || Date.now());
|
|
76
|
+
return from <= end && to >= start;
|
|
77
|
+
})
|
|
78
|
+
.flatMap((d) => d.admit.owns);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The tree after the run, or null when git cannot say: the record is still written, its changes unknown. */
|
|
82
|
+
async function snapshotOrNull(repo: string): Promise<Snapshot | null> {
|
|
83
|
+
try {
|
|
84
|
+
return await statusSnapshot(repo);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (isCatherdError(e) && e.code === "E_IO_UNEXPECTED") return null;
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* How long a backend may take to settle a finished session before its stream's own figures stand, and
|
|
93
|
+
* how much longer than that and the git snapshot's timeout (gitLimits) a finalizer's claim may live (its file
|
|
94
|
+
* work and the append) before a second finalizer takes it over.
|
|
95
|
+
*/
|
|
96
|
+
export const settleLimits = { timeoutMs: 20_000, claimMarginMs: 10_000 };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What earlier records on `thread` already counted: a resumed session's totals repeat them. Every run's,
|
|
100
|
+
* since a role may resume a thread an earlier catherd run started.
|
|
101
|
+
*/
|
|
102
|
+
function priorOn(run: Run, backend: string, thread: string, self: string): Spent {
|
|
103
|
+
const earlier = recordsOnThread(run, backend, thread).filter((r) => r.dispatchId !== self);
|
|
104
|
+
return {
|
|
105
|
+
tokens: {
|
|
106
|
+
input: earlier.reduce((n, r) => n + r.tokens.input, 0),
|
|
107
|
+
cached: earlier.reduce((n, r) => n + r.tokens.cached, 0),
|
|
108
|
+
output: earlier.reduce((n, r) => n + r.tokens.output, 0),
|
|
109
|
+
},
|
|
110
|
+
costUsd: earlier.reduce((n, r) => n + (r.costUsd ?? 0), 0),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The adapter's settled outcome, or `o` unchanged when it has none, throws, or is slower than the limit. */
|
|
115
|
+
export async function settled(
|
|
116
|
+
adapter: BackendAdapter,
|
|
117
|
+
o: Outcome,
|
|
118
|
+
finished: FinishedRun,
|
|
119
|
+
prior: () => Spent,
|
|
120
|
+
): Promise<Outcome> {
|
|
121
|
+
if (!adapter.settle || o.thread === null) return o;
|
|
122
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
123
|
+
try {
|
|
124
|
+
const late = new Promise<Outcome>((resolve) => {
|
|
125
|
+
timer = setTimeout(() => resolve(o), settleLimits.timeoutMs);
|
|
126
|
+
});
|
|
127
|
+
return await Promise.race([adapter.settle(o, finished, prior()), late]);
|
|
128
|
+
} catch {
|
|
129
|
+
return o;
|
|
130
|
+
} finally {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
136
|
+
const a = d.admit;
|
|
137
|
+
const p = dispatchPaths(d.dir);
|
|
138
|
+
// no exit.json: its supervisor died. A cancel asked for before that ended it; anything else is lost.
|
|
139
|
+
const exit: ExitInfo = readExit(d.dir) ?? {
|
|
140
|
+
code: null,
|
|
141
|
+
signal: null,
|
|
142
|
+
reason: existsSync(p.cancel) ? "cancelled" : "lost",
|
|
143
|
+
endedAt: new Date().toISOString(),
|
|
144
|
+
};
|
|
145
|
+
const startedAt = readProc(d.dir)?.startedAt ?? a.admittedAt;
|
|
146
|
+
const reply = text(p.reply);
|
|
147
|
+
const finished: FinishedRun = {
|
|
148
|
+
request: {
|
|
149
|
+
rung: parseRung(a.rung),
|
|
150
|
+
access: a.access,
|
|
151
|
+
thread: a.thread,
|
|
152
|
+
isolated: a.isolated,
|
|
153
|
+
repo: a.repo,
|
|
154
|
+
briefPath: p.brief,
|
|
155
|
+
replyPath: p.reply,
|
|
156
|
+
dispatchDir: d.dir,
|
|
157
|
+
},
|
|
158
|
+
eventLines: nonBlankLines(p.events),
|
|
159
|
+
reply,
|
|
160
|
+
stderr: text(p.stderr),
|
|
161
|
+
exit,
|
|
162
|
+
startedAtMs: Date.parse(startedAt),
|
|
163
|
+
};
|
|
164
|
+
const adapter = adapterFor(a.backend);
|
|
165
|
+
const first: Outcome = adapter
|
|
166
|
+
? adapter.finalize(finished)
|
|
167
|
+
: {
|
|
168
|
+
status: "failed",
|
|
169
|
+
thread: a.thread,
|
|
170
|
+
tokens: { ...ZERO_TOKENS },
|
|
171
|
+
costUsd: null,
|
|
172
|
+
images: [],
|
|
173
|
+
error: { code: "E_BACKEND_MISSING", message: `catherd has no ${a.backend} adapter` },
|
|
174
|
+
};
|
|
175
|
+
const o = adapter
|
|
176
|
+
? await settled(adapter, first, finished, () => priorOn(run, a.backend, first.thread ?? "", a.dispatchId))
|
|
177
|
+
: first;
|
|
178
|
+
// A CLI that streams its reply (claude, opencode) leaves reply.md to catherd.
|
|
179
|
+
if (o.reply !== undefined && o.reply !== reply) writeTextAtomic(p.reply, o.reply);
|
|
180
|
+
// one the CLI wrote itself (codex) has the CLI's mode
|
|
181
|
+
else makePrivate(p.reply);
|
|
182
|
+
const replyText = o.reply ?? reply;
|
|
183
|
+
const start = Date.parse(startedAt);
|
|
184
|
+
const end = Date.parse(exit.endedAt);
|
|
185
|
+
const after = await snapshotOrNull(a.repo);
|
|
186
|
+
const { changedOwned, violations } = after
|
|
187
|
+
? splitChanges(
|
|
188
|
+
changedPaths(a.before, after),
|
|
189
|
+
a.owns,
|
|
190
|
+
// from admission, when the before-snapshot was taken, not from the worker's start
|
|
191
|
+
othersOwns(run, d, Date.parse(a.admittedAt), end),
|
|
192
|
+
a.lane !== null || a.access === "read-only",
|
|
193
|
+
)
|
|
194
|
+
: { changedOwned: [], violations: [] };
|
|
195
|
+
const reported = parseReplyStatus(replyText);
|
|
196
|
+
return {
|
|
197
|
+
schema: 1,
|
|
198
|
+
runId: run.id,
|
|
199
|
+
dispatchId: a.dispatchId,
|
|
200
|
+
name: a.name,
|
|
201
|
+
role: a.role,
|
|
202
|
+
lane: a.lane,
|
|
203
|
+
backend: a.backend,
|
|
204
|
+
rung: a.rung,
|
|
205
|
+
attempt: a.attempt,
|
|
206
|
+
failoverFrom: a.failoverFrom,
|
|
207
|
+
thread: o.thread,
|
|
208
|
+
status: o.status,
|
|
209
|
+
startedAt,
|
|
210
|
+
endedAt: exit.endedAt,
|
|
211
|
+
secs: Math.max(0, Math.round((end - start) / 1000)),
|
|
212
|
+
exitCode: exit.code,
|
|
213
|
+
signal: exit.signal,
|
|
214
|
+
cliVersion: a.cliVersion,
|
|
215
|
+
tokens: o.tokens,
|
|
216
|
+
costUsd: o.costUsd,
|
|
217
|
+
changedOwned,
|
|
218
|
+
violations,
|
|
219
|
+
...(after ? {} : { gitUnavailable: true }),
|
|
220
|
+
replyStatus: reported.status,
|
|
221
|
+
replyWhy: reported.why,
|
|
222
|
+
threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
|
|
223
|
+
access: a.access,
|
|
224
|
+
isolated: a.isolated,
|
|
225
|
+
images: o.images,
|
|
226
|
+
error: o.error,
|
|
227
|
+
replyPath: relative(run.dir, p.reply),
|
|
228
|
+
...(a.sessionId ? { sessionId: a.sessionId } : {}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A fresh thread's first-turn input, for the harness-cost line of runs_summary: its first model request's
|
|
234
|
+
* input, from a backend whose stream reports usage per request. Codex reports it per exec: no row.
|
|
235
|
+
*/
|
|
236
|
+
function recordHarness(run: Run, d: Dispatch, r: RunRecord): void {
|
|
237
|
+
const a = adapterFor(d.admit.backend);
|
|
238
|
+
if (d.admit.thread !== null || !a) return;
|
|
239
|
+
for (const line of nonBlankLines(dispatchPaths(d.dir).events)) {
|
|
240
|
+
let input;
|
|
241
|
+
try {
|
|
242
|
+
input = a.parse(line).requestInput;
|
|
243
|
+
} catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (input === undefined) continue;
|
|
247
|
+
const file = runPaths(run.dir).harness;
|
|
248
|
+
ensureJsonlHeader(file, "harness");
|
|
249
|
+
appendJsonl(file, {
|
|
250
|
+
at: r.endedAt,
|
|
251
|
+
name: r.name,
|
|
252
|
+
backend: r.backend,
|
|
253
|
+
isolated: r.isolated,
|
|
254
|
+
firstTurnInput: input,
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Spec §3.3: the first finalizer claims the dispatch and writes its record; any other waits for that
|
|
262
|
+
* record and returns it. If the claimer died before appending, the late finalizer appends instead;
|
|
263
|
+
* appendRecord's per-dispatch dedupe keeps that to one record either way (audit C2). A claimer that throws
|
|
264
|
+
* releases its claim.
|
|
265
|
+
*
|
|
266
|
+
* In this process, a second finalizer (`cancel` beside the dispatch's watcher) joins the first one's
|
|
267
|
+
* promise. Across processes it waits for the claimant's record, and takes over only once the claim is
|
|
268
|
+
* stale (claimant dead, or past its settle window): never while a live claimant may still be settling.
|
|
269
|
+
*/
|
|
270
|
+
export function finalizeDispatch(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
271
|
+
const id = d.admit.dispatchId;
|
|
272
|
+
const joined = inFlight.get(id);
|
|
273
|
+
if (joined) return joined;
|
|
274
|
+
const p = finalizeOnce(run, d).finally(() => inFlight.delete(id));
|
|
275
|
+
inFlight.set(id, p);
|
|
276
|
+
return p;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** This process's finalizes in flight, by dispatch id. */
|
|
280
|
+
const inFlight = new Map<string, Promise<RunRecord>>();
|
|
281
|
+
|
|
282
|
+
const recordOf = (run: Run, id: string): RunRecord | undefined =>
|
|
283
|
+
readRecords(run).records.find((r) => r.dispatchId === id);
|
|
284
|
+
|
|
285
|
+
/** Test seam: runs after a finalizer judged a claim stale, before it takes the lock to take it over. */
|
|
286
|
+
export const claimSeams = { beforeTakeover: async (_dir: string): Promise<void> => {} };
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Takes over a stale claim, serialized by a lock on the claim: under it, reads the claim again, removes it
|
|
290
|
+
* only if it is still stale, and claims it. True for the one caller that now holds the claim; a caller that
|
|
291
|
+
* finds it live (another took it over, or its claimant woke) or loses the claim to a fresh claimant gets
|
|
292
|
+
* false and goes back to waiting for the record. A claim is removed only here and by its own claimant, and
|
|
293
|
+
* here only right before claiming it, so there is never a second live computer, and never none for long:
|
|
294
|
+
* a claim that vanished is claimed by the next finalizer's poll.
|
|
295
|
+
*/
|
|
296
|
+
export async function takeOverStaleClaim(dir: string): Promise<boolean> {
|
|
297
|
+
const claim = dispatchPaths(dir).claim;
|
|
298
|
+
return withFileLock(claim, () => {
|
|
299
|
+
if (existsSync(claim)) {
|
|
300
|
+
if (!claimStale(dir)) return false;
|
|
301
|
+
rmSync(claim, { force: true });
|
|
302
|
+
}
|
|
303
|
+
return tryClaim(dir);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function finalizeOnce(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
308
|
+
for (;;) {
|
|
309
|
+
const existing = recordOf(run, d.admit.dispatchId);
|
|
310
|
+
if (existing) return existing;
|
|
311
|
+
// a claim released by a claimer that threw is gone: this claims it
|
|
312
|
+
if (tryClaim(d.dir)) break;
|
|
313
|
+
if (existsSync(dispatchPaths(d.dir).claim) && claimStale(d.dir)) {
|
|
314
|
+
await claimSeams.beforeTakeover(d.dir);
|
|
315
|
+
if (await takeOverStaleClaim(d.dir)) break;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
await Bun.sleep(50);
|
|
319
|
+
}
|
|
320
|
+
let saved: RunRecord;
|
|
321
|
+
let mine: RunRecord;
|
|
322
|
+
try {
|
|
323
|
+
mine = await compute(run, d);
|
|
324
|
+
saved = await appendRecord(run, mine);
|
|
325
|
+
} catch (e) {
|
|
326
|
+
// no record: let the next finalizer try at once rather than wait on this claim; ours only, since a
|
|
327
|
+
// claim held past its window may have been taken over
|
|
328
|
+
if (readClaimant(d.dir)?.pid === process.pid) rmSync(dispatchPaths(d.dir).claim, { force: true });
|
|
329
|
+
throw e;
|
|
330
|
+
}
|
|
331
|
+
if (saved === mine) recordHarness(run, d, mine);
|
|
332
|
+
return saved;
|
|
333
|
+
}
|
|
334
|
+
|
|
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 {
|
|
348
|
+
const a = adapterFor(d.admit.backend);
|
|
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;
|
|
360
|
+
}
|
|
361
|
+
return name;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Waits until the dispatch finishes, polling every `pollMs`; `onPoll` runs at each poll, and never ends the wait. */
|
|
365
|
+
export async function waitForFinish(
|
|
366
|
+
d: Dispatch,
|
|
367
|
+
o: { pollMs: number; now: () => number; onPoll?: () => void },
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
while (dispatchState(d, o.now()) !== "finished") {
|
|
370
|
+
await Bun.sleep(o.pollMs);
|
|
371
|
+
try {
|
|
372
|
+
o.onPoll?.();
|
|
373
|
+
} catch {
|
|
374
|
+
// a report on the way must never stop the wait
|
|
375
|
+
}
|
|
376
|
+
}
|
|
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
|
+
}
|