glm-coding-router 1.1.1 → 2.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 (39) hide show
  1. package/README.md +534 -419
  2. package/dist/bin/glm-review.js +46 -4
  3. package/dist/bin/glm-worker.js +37 -4
  4. package/dist/budget/estimator.js +218 -0
  5. package/dist/budget/manager.js +223 -0
  6. package/dist/cli.js +38 -0
  7. package/dist/commands/benchmark.js +4 -0
  8. package/dist/commands/dashboard.js +348 -0
  9. package/dist/commands/runs.js +568 -0
  10. package/dist/commands/usage.js +1 -40
  11. package/dist/commands/watch.js +289 -0
  12. package/dist/core/agent-args.js +20 -0
  13. package/dist/core/config.js +61 -0
  14. package/dist/core/errors.js +24 -0
  15. package/dist/core/paths.js +32 -0
  16. package/dist/core/process.js +83 -0
  17. package/dist/core/prompt.js +18 -5
  18. package/dist/core/routing-flags.js +59 -0
  19. package/dist/core/zai-quota.js +46 -0
  20. package/dist/events/bus.js +64 -0
  21. package/dist/events/claude-adapter.js +416 -0
  22. package/dist/events/types.js +9 -0
  23. package/dist/handoff/bundle.js +203 -0
  24. package/dist/handoff/parent-handoff.js +48 -0
  25. package/dist/mcp/server.js +45 -1
  26. package/dist/routing/glm-routing.js +131 -0
  27. package/dist/runs/checkpoint.js +204 -0
  28. package/dist/runs/drain.js +165 -0
  29. package/dist/runs/heartbeat.js +45 -0
  30. package/dist/runs/registry.js +350 -0
  31. package/dist/runs/store.js +186 -0
  32. package/dist/runs/ulid.js +112 -0
  33. package/dist/runs/worker-run.js +672 -0
  34. package/dist/templates/agents-block.js +9 -0
  35. package/dist/templates/claude-block.js +9 -0
  36. package/dist/templates/glm-delegation-skill.js +76 -65
  37. package/dist/tui/progress.js +338 -0
  38. package/dist/tui/render.js +78 -0
  39. package/package.json +1 -1
@@ -0,0 +1,165 @@
1
+ import { execFile } from "node:child_process";
2
+ import { logger } from "../core/logging.js";
3
+ import { zoneFor } from "../budget/manager.js";
4
+ import { usableBudgetOf } from "../routing/glm-routing.js";
5
+ /**
6
+ * Grace between SIGINT and SIGTERM. Five seconds is enough for `claude` to
7
+ * finish flushing the tool result it is holding and exit on its own — the
8
+ * point of the ladder is to let it close cleanly, not to win a race.
9
+ */
10
+ export const TERMINATE_GRACE_MS = 5_000;
11
+ /** Second grace, before Windows' last resort. Short: by now it has ignored two signals. */
12
+ export const TASKKILL_GRACE_MS = 2_000;
13
+ /** Worst-to-best, so "at or below HANDOFF_READY" is one comparison. */
14
+ const ZONE_SEVERITY = {
15
+ CRITICAL: 3,
16
+ HANDOFF_READY: 2,
17
+ CONSERVE: 1,
18
+ HEALTHY: 0,
19
+ };
20
+ /**
21
+ * Should this run be worried yet? (specs/v2-architecture.md Phase F.)
22
+ *
23
+ * Pure: the caller does the polling and owns every side effect. Note what this
24
+ * projects — the cost of what is LEFT, not of the whole task. A run three
25
+ * quarters done needs a quarter of the estimate, and treating it as if it were
26
+ * starting over would raise the alarm on every long run that is nearly
27
+ * finished, which is precisely when interrupting costs the most.
28
+ *
29
+ * `confidence: "unknown"` can never reach `atRisk`, because `zoneFor` fails
30
+ * open to HEALTHY — a monitoring outage must not drain a live child.
31
+ */
32
+ export function assessDrain(input) {
33
+ const routing = input.config.routing;
34
+ const zone = zoneFor(input.snapshot, routing);
35
+ const usableBudget = usableBudgetOf(input.snapshot, routing.reserveRatio);
36
+ // A run that has already used every turn it was given has nothing left to
37
+ // project; an unknown/zero ceiling means "assume it all still lies ahead".
38
+ const remainingTurnRatio = input.maxTurns > 0
39
+ ? Math.min(1, Math.max(0, (input.maxTurns - input.turnsDone) / input.maxTurns))
40
+ : 1;
41
+ const projectedCost = input.estimate.p90 * routing.safetyFactor * remainingTurnRatio;
42
+ return {
43
+ zone,
44
+ usableBudget,
45
+ projectedCost,
46
+ atRisk: ZONE_SEVERITY[zone] >= ZONE_SEVERITY.HANDOFF_READY && projectedCost > usableBudget,
47
+ };
48
+ }
49
+ /**
50
+ * Polls the budget while the child runs and calls back the first time the run
51
+ * becomes at-risk, then never again: the warning and the checkpoint are worth
52
+ * writing once, and a per-poll repeat would turn a tight quota into a wall of
53
+ * identical stderr lines.
54
+ *
55
+ * Every timer is injectable because a test must not wait a real minute, and
56
+ * the interval is unref'd so a poll in flight can never hold the process open
57
+ * past the run it was watching.
58
+ */
59
+ export function startDrainWatch(input) {
60
+ const setIntervalFn = input.setIntervalImpl ?? ((tick, ms) => setInterval(tick, ms));
61
+ const clearIntervalFn = input.clearIntervalImpl ?? ((handle) => clearInterval(handle));
62
+ let fired = false;
63
+ let stopped = false;
64
+ const tick = () => {
65
+ if (fired || stopped) {
66
+ return;
67
+ }
68
+ // Fire-and-forget: a poll that rejects must not become an unhandled
69
+ // rejection, and a slow endpoint must not delay the next tick.
70
+ void (async () => {
71
+ try {
72
+ const assessment = assessDrain({
73
+ snapshot: await input.readBudget(),
74
+ estimate: input.estimate(),
75
+ config: input.config,
76
+ turnsDone: input.turnsDone(),
77
+ maxTurns: input.maxTurns,
78
+ });
79
+ if (assessment.atRisk && !fired && !stopped) {
80
+ fired = true;
81
+ input.onAtRisk(assessment);
82
+ }
83
+ }
84
+ catch (error) {
85
+ logger.debug(`drain watch: poll failed, continuing: ${errorMessage(error)}`);
86
+ }
87
+ })();
88
+ };
89
+ const handle = setIntervalFn(tick, Math.max(1, input.config.routing.pollIntervalSec) * 1000);
90
+ handle.unref?.();
91
+ return {
92
+ stop() {
93
+ stopped = true;
94
+ clearIntervalFn(handle);
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Stop the child at a safe boundary: SIGINT, grace, SIGTERM, and on Windows
100
+ * `taskkill /pid <pid> /t` as the last resort — the same ladder and the same
101
+ * no-shell rule the spawn helpers already follow.
102
+ *
103
+ * Windows has no real POSIX signals: Node emulates `kill` by terminating the
104
+ * process, and a `claude` that spawned its own children leaves them behind,
105
+ * which is what `/t` (kill the tree) is for. `/f` is deliberately NOT used
106
+ * before the tree walk — a forced kill is the thing this whole ladder exists
107
+ * to avoid, because it is what loses the work on disk.
108
+ *
109
+ * Resolves when the child is gone or the ladder is exhausted; never throws.
110
+ */
111
+ export async function terminateChild(child, options = {}) {
112
+ const platform = options.platform ?? process.platform;
113
+ const wait = options.waitImpl ?? defaultWait;
114
+ const exited = () => child.exitCode !== null || child.signalCode !== null;
115
+ send(child, "SIGINT");
116
+ if (exited()) {
117
+ return;
118
+ }
119
+ await wait(options.graceMs ?? TERMINATE_GRACE_MS);
120
+ if (exited()) {
121
+ return;
122
+ }
123
+ send(child, "SIGTERM");
124
+ if (platform !== "win32") {
125
+ return;
126
+ }
127
+ await wait(options.taskkillGraceMs ?? TASKKILL_GRACE_MS);
128
+ if (exited() || typeof child.pid !== "number") {
129
+ return;
130
+ }
131
+ const taskkill = options.runTaskkill ?? defaultTaskkill;
132
+ try {
133
+ taskkill(child.pid);
134
+ }
135
+ catch (error) {
136
+ logger.debug(`terminateChild: taskkill failed: ${errorMessage(error)}`);
137
+ }
138
+ }
139
+ /** A kill on an already-dead child throws ESRCH; that is success, not an error. */
140
+ function send(child, signal) {
141
+ try {
142
+ child.kill(signal);
143
+ }
144
+ catch (error) {
145
+ logger.debug(`terminateChild: ${signal} failed: ${errorMessage(error)}`);
146
+ }
147
+ }
148
+ /** argv array, no shell — the same rule every spawn in this package follows. */
149
+ function defaultTaskkill(pid) {
150
+ execFile("taskkill", ["/pid", String(pid), "/t"], { windowsHide: true }, (error) => {
151
+ if (error) {
152
+ logger.debug(`taskkill /pid ${pid} /t failed: ${error.message}`);
153
+ }
154
+ });
155
+ }
156
+ function defaultWait(ms) {
157
+ return new Promise((resolve) => {
158
+ // unref: a pending grace period must never keep the process alive.
159
+ const timer = setTimeout(resolve, ms);
160
+ timer.unref?.();
161
+ });
162
+ }
163
+ function errorMessage(error) {
164
+ return error instanceof Error ? error.message : String(error);
165
+ }
@@ -0,0 +1,45 @@
1
+ import { logger } from "../core/logging.js";
2
+ import { updateRun } from "./registry.js";
3
+ /**
4
+ * Doc §6 fixes the tick at 5 s and keeps it out of config on purpose: the
5
+ * 30 s orphan threshold is six missed ticks of THIS interval. A config knob
6
+ * here would let anyone silently break liveness detection.
7
+ */
8
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
9
+ /**
10
+ * Emits a `Heartbeat` event and refreshes `heartbeatAt` in the active file on
11
+ * every tick — the pair that `watch` and orphan detection read. A file
12
+ * failure is caught and logged at debug: the heartbeat is liveness plumbing,
13
+ * and plumbing must never kill the run it is monitoring. The bus's own
14
+ * subscriber guard covers the emit side.
15
+ */
16
+ export function startHeartbeat(deps) {
17
+ const intervalMs = deps.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
18
+ const tick = () => {
19
+ deps.bus.emit({ type: "Heartbeat", state: deps.getState(), turn: deps.getTurn() });
20
+ try {
21
+ updateRun(deps.home, deps.runId, { heartbeatAt: new Date().toISOString() });
22
+ }
23
+ catch (error) {
24
+ logger.debug(`heartbeat: could not refresh ${deps.runId}: ${errorMessage(error)}`);
25
+ }
26
+ };
27
+ const setIntervalImpl = deps.setIntervalImpl ?? ((fn, ms) => setInterval(fn, ms));
28
+ const clearIntervalImpl = deps.clearIntervalImpl ?? ((timer) => clearInterval(timer));
29
+ let timer = setIntervalImpl(tick, intervalMs);
30
+ // Unref when the runtime supports it: a leaked heartbeat timer must never
31
+ // be the reason the process stays open after the run finished.
32
+ timer.unref?.();
33
+ return {
34
+ stop() {
35
+ if (timer === null) {
36
+ return;
37
+ }
38
+ clearIntervalImpl(timer);
39
+ timer = null;
40
+ },
41
+ };
42
+ }
43
+ function errorMessage(error) {
44
+ return error instanceof Error ? error.message : String(error);
45
+ }
@@ -0,0 +1,350 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { logger, redact } from "../core/logging.js";
5
+ import { activeRunFile, activeRunsDir, runDir, runsDir } from "../core/paths.js";
6
+ import { atomicWriteFile } from "../project/atomic-write.js";
7
+ import { ZAI_API_KEY_ENV } from "../core/zai-key.js";
8
+ import { eventsFilePath } from "./store.js";
9
+ /** C3: 120 chars is the widest a task title may ever be on disk or in a line. */
10
+ export const TASK_TITLE_MAX_CHARS = 120;
11
+ /**
12
+ * A heartbeat older than this (30 s = six missed 5 s ticks) plus a dead pid
13
+ * is what makes a run orphaned. The threshold is a property of the liveness
14
+ * protocol, not a user knob — tuning it without the heartbeat interval would
15
+ * silently break orphan detection.
16
+ */
17
+ export const ORPHAN_HEARTBEAT_MS = 30_000;
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+ /**
20
+ * C3: the first line of the prompt, redacted, cut to 120 chars — the only
21
+ * prompt-derived text ever persisted.
22
+ *
23
+ * `secrets` exists because the process environment is NOT where the key
24
+ * lives on the platform this ships to: `resolveZaiApiKey` reads it from the
25
+ * Windows User Environment (or a keychain) precisely so it stays out of
26
+ * `process.env`. A caller that already resolved the key passes it here; the
27
+ * env var is only a fallback for when it happens to be exported.
28
+ */
29
+ export function taskTitleOf(prompt, secrets = []) {
30
+ const firstLine = prompt.split(/\r?\n/, 1)[0] ?? "";
31
+ return redact(firstLine, [...secrets, process.env[ZAI_API_KEY_ENV]]).slice(0, TASK_TITLE_MAX_CHARS);
32
+ }
33
+ /**
34
+ * C3: sha256 of the FULL prompt. The hash lets the estimator recognize the
35
+ * same task again while the body itself never touches disk.
36
+ */
37
+ export function taskHashOf(prompt) {
38
+ return createHash("sha256").update(prompt, "utf8").digest("hex");
39
+ }
40
+ /**
41
+ * Registers a run and creates its history directory immediately — not at the
42
+ * end — so `watch` can attach to a run that is still going. Only this
43
+ * function propagates filesystem errors: a run whose registry entry cannot be
44
+ * written exists nowhere, and the caller must decide what to do about that.
45
+ */
46
+ export function createRun(meta, deps) {
47
+ fs.mkdirSync(runDir(deps.home, meta.date, meta.id), { recursive: true });
48
+ fs.mkdirSync(activeRunsDir(deps.home), { recursive: true });
49
+ const active = {
50
+ ...meta,
51
+ state: "RUNNING",
52
+ heartbeatAt: (deps.now?.() ?? new Date()).toISOString(),
53
+ };
54
+ atomicWriteFile(activeRunFile(deps.home, meta.id), serialize(active));
55
+ return active;
56
+ }
57
+ /**
58
+ * Atomically patches the active file. Returns null — never throws — when the
59
+ * entry is missing or corrupt: a stale registry file must not take the run
60
+ * down with it, and callers (the heartbeat) treat null as "skip quietly".
61
+ */
62
+ export function updateRun(home, id, patch) {
63
+ const file = activeRunFile(home, id);
64
+ try {
65
+ const current = readJsonFile(file);
66
+ if (!isStoredRun(current)) {
67
+ logger.debug(`updateRun: ${file} is missing or not a run entry`);
68
+ return null;
69
+ }
70
+ const next = { ...current, ...patch };
71
+ atomicWriteFile(file, serialize(next));
72
+ return next;
73
+ }
74
+ catch (error) {
75
+ logger.debug(`updateRun: patching ${id} failed: ${errorMessage(error)}`);
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Writes `summary.json` into the run's history directory, then deletes the
81
+ * active file — that order is what makes "active file gone but no summary" a
82
+ * crash signature rather than a possible intermediate state of a clean run.
83
+ * Never throws: a run that already finished is not worth failing `finishRun`
84
+ * over, so every failure is logged at debug and skipped.
85
+ */
86
+ export function finishRun(home, id, summary) {
87
+ const dir = locateRunDir(home, id);
88
+ if (dir === null) {
89
+ logger.debug(`finishRun: no history directory found for ${id}`);
90
+ return;
91
+ }
92
+ try {
93
+ atomicWriteFile(path.join(dir, "summary.json"), serialize(summary));
94
+ }
95
+ catch (error) {
96
+ logger.debug(`finishRun: writing summary for ${id} failed: ${errorMessage(error)}`);
97
+ return;
98
+ }
99
+ try {
100
+ fs.rmSync(activeRunFile(home, id), { force: true });
101
+ }
102
+ catch (error) {
103
+ logger.debug(`finishRun: removing active file for ${id} failed: ${errorMessage(error)}`);
104
+ }
105
+ }
106
+ /** Every registered run that claims to be alive, newest first. Corrupt files are skipped, never fatal. */
107
+ export function listActive(home) {
108
+ const dir = activeRunsDir(home);
109
+ let names;
110
+ try {
111
+ names = fs.readdirSync(dir);
112
+ }
113
+ catch {
114
+ return [];
115
+ }
116
+ const runs = [];
117
+ for (const name of names) {
118
+ if (!name.endsWith(".json")) {
119
+ continue;
120
+ }
121
+ const entry = readJsonFile(path.join(dir, name));
122
+ if (isStoredRun(entry)) {
123
+ runs.push(entry);
124
+ }
125
+ else {
126
+ logger.debug(`listActive: skipping unreadable entry ${name}`);
127
+ }
128
+ }
129
+ // ULIDs sort chronologically, so id order is start order with no parsing.
130
+ return runs.sort((a, b) => (a.id < b.id ? 1 : -1));
131
+ }
132
+ /** History entries, newest first, optionally filtered by kind and/or terminal state. */
133
+ export function listHistory(home, filter = {}) {
134
+ const refs = [];
135
+ for (const date of historyDates(home)) {
136
+ for (const id of runIdsIn(path.join(historyRoot(home), date))) {
137
+ const ref = readSummaryRef(home, date, id);
138
+ if (ref !== null &&
139
+ (filter.kind === undefined || ref.kind === filter.kind) &&
140
+ (filter.state === undefined || ref.state === filter.state)) {
141
+ refs.push(ref);
142
+ }
143
+ }
144
+ }
145
+ // Dates and ids were read sorted ascending; reverse for newest-first.
146
+ return refs.reverse();
147
+ }
148
+ /**
149
+ * Enforces `retentionDays` then `maxRuns` (oldest first), called
150
+ * opportunistically — a pruning failure must never block the work around it.
151
+ * Returns the removed run ids so `runs clean` can say what it did.
152
+ */
153
+ export function pruneHistory(home, limits) {
154
+ const now = Date.now();
155
+ const entries = [];
156
+ for (const date of historyDates(home)) {
157
+ const dateMs = Date.parse(`${date}T00:00:00.000Z`);
158
+ if (!Number.isFinite(dateMs)) {
159
+ logger.debug(`pruneHistory: skipping non-date directory ${date}`);
160
+ continue;
161
+ }
162
+ for (const id of runIdsIn(path.join(historyRoot(home), date))) {
163
+ entries.push({ date, id, dateMs });
164
+ }
165
+ }
166
+ const expired = entries.filter((entry) => now - entry.dateMs >= limits.retentionDays * DAY_MS);
167
+ const kept = entries
168
+ .filter((entry) => !expired.includes(entry))
169
+ .sort((a, b) => (a.date === b.date ? (a.id < b.id ? -1 : 1) : a.date < b.date ? -1 : 1));
170
+ // Oldest first, only as many as exceed the cap.
171
+ const overflow = kept.slice(0, Math.max(0, kept.length - limits.maxRuns));
172
+ const removed = [];
173
+ const touchedDates = new Set();
174
+ for (const entry of [...expired, ...overflow]) {
175
+ try {
176
+ fs.rmSync(runDir(home, entry.date, entry.id), { recursive: true, force: true });
177
+ removed.push(entry.id);
178
+ touchedDates.add(entry.date);
179
+ }
180
+ catch (error) {
181
+ logger.debug(`pruneHistory: removing ${entry.id} failed: ${errorMessage(error)}`);
182
+ }
183
+ }
184
+ for (const date of touchedDates) {
185
+ try {
186
+ fs.rmdirSync(path.join(historyRoot(home), date));
187
+ }
188
+ catch {
189
+ // Directory not empty (a kept/failed removal lives there) — nothing to do.
190
+ }
191
+ }
192
+ return { removed };
193
+ }
194
+ /**
195
+ * True when the heartbeat is stale AND the process is gone. Both conditions:
196
+ * a fresh heartbeat with a dead pid is a run that just hasn't ticked again,
197
+ * and a stale heartbeat with a live pid is a busy worker, not an orphan.
198
+ */
199
+ export function isOrphaned(run, deps = {}) {
200
+ const now = deps.now?.() ?? Date.now();
201
+ const heartbeatAge = now - Date.parse(run.heartbeatAt);
202
+ // A NaN age (unparseable heartbeatAt) fails the comparison, so a corrupt
203
+ // entry is treated as fresh rather than reaped on a technicality.
204
+ if (!(heartbeatAge > ORPHAN_HEARTBEAT_MS)) {
205
+ return false;
206
+ }
207
+ const isAlive = deps.isAlive ?? defaultIsAlive;
208
+ return !isAlive(run.pid);
209
+ }
210
+ /**
211
+ * The liveness probe: `process.kill(pid, 0)` sends no signal, it only asks
212
+ * the OS whether the process exists. EPERM means it exists but belongs to
213
+ * someone else — that is a live pid, not a dead one.
214
+ */
215
+ function defaultIsAlive(pid) {
216
+ try {
217
+ process.kill(pid, 0);
218
+ return true;
219
+ }
220
+ catch (error) {
221
+ return error.code === "EPERM";
222
+ }
223
+ }
224
+ function historyRoot(home) {
225
+ return path.join(runsDir(home), "history");
226
+ }
227
+ /** Date directories, sorted oldest first so consumers can rely on iteration order. */
228
+ function historyDates(home) {
229
+ try {
230
+ return fs
231
+ .readdirSync(historyRoot(home), { withFileTypes: true })
232
+ .filter((entry) => entry.isDirectory())
233
+ .map((entry) => entry.name)
234
+ .sort();
235
+ }
236
+ catch {
237
+ return [];
238
+ }
239
+ }
240
+ function runIdsIn(dir) {
241
+ try {
242
+ return fs
243
+ .readdirSync(dir, { withFileTypes: true })
244
+ .filter((entry) => entry.isDirectory())
245
+ .map((entry) => entry.name)
246
+ .sort();
247
+ }
248
+ catch {
249
+ return [];
250
+ }
251
+ }
252
+ /**
253
+ * Builds one history entry. `summary.json` answers "how did it end"; when it
254
+ * is missing the run crashed, and kind/startedAt come from the first line of
255
+ * `events.jsonl` (a `RunStarted`, written before anything could crash).
256
+ */
257
+ function readSummaryRef(home, date, id) {
258
+ const dir = runDir(home, date, id);
259
+ const summary = readJsonFile(path.join(dir, "summary.json"));
260
+ const firstEvent = readFirstEvent(dir);
261
+ if (!isSummary(summary) && firstEvent === null) {
262
+ // Neither artifact is readable — debris, not a run worth listing.
263
+ logger.debug(`listHistory: skipping ${dir} (no readable summary or events)`);
264
+ return null;
265
+ }
266
+ const state = isSummary(summary) ? summary.state : "CRASHED";
267
+ return {
268
+ id,
269
+ date,
270
+ kind: firstEvent?.kind ?? null,
271
+ state,
272
+ startedAt: firstEvent?.startedAt ?? null,
273
+ summary: isSummary(summary) ? summary : null,
274
+ };
275
+ }
276
+ /** The first line of `events.jsonl`, or null — reading more would make a listing O(stream). */
277
+ function readFirstEvent(dir) {
278
+ try {
279
+ const firstLine = fs.readFileSync(eventsFilePath(dir), "utf8").split(/\r?\n/, 1)[0] ?? "";
280
+ if (firstLine.trim() === "") {
281
+ return null;
282
+ }
283
+ const parsed = JSON.parse(firstLine);
284
+ if (typeof parsed !== "object" || parsed === null) {
285
+ return null;
286
+ }
287
+ const record = parsed;
288
+ if (typeof record.ts !== "string") {
289
+ return null;
290
+ }
291
+ return {
292
+ kind: typeof record.kind === "string" ? record.kind : null,
293
+ startedAt: record.ts,
294
+ };
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
300
+ /**
301
+ * Where a run's history directory is. The active file is the fast path; the
302
+ * scan exists so `finishRun` still works when the active file was already
303
+ * reaped (crashed process, later cleanup).
304
+ */
305
+ function locateRunDir(home, id) {
306
+ const entry = readJsonFile(activeRunFile(home, id));
307
+ if (isStoredRun(entry)) {
308
+ const dir = runDir(home, entry.date, id);
309
+ if (fs.existsSync(dir)) {
310
+ return dir;
311
+ }
312
+ }
313
+ for (const date of historyDates(home)) {
314
+ const dir = runDir(home, date, id);
315
+ if (fs.existsSync(dir)) {
316
+ return dir;
317
+ }
318
+ }
319
+ return null;
320
+ }
321
+ function readJsonFile(file) {
322
+ try {
323
+ return JSON.parse(fs.readFileSync(file, "utf8"));
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ }
329
+ /**
330
+ * Deliberately shallow: enough to know the file is a run entry, not a full
331
+ * schema check — a future field must not make every old entry unreadable.
332
+ */
333
+ function isStoredRun(value) {
334
+ if (typeof value !== "object" || value === null) {
335
+ return false;
336
+ }
337
+ const record = value;
338
+ return (typeof record.id === "string" &&
339
+ typeof record.state === "string" &&
340
+ typeof record.heartbeatAt === "string");
341
+ }
342
+ function isSummary(value) {
343
+ return typeof value === "object" && value !== null && typeof value.state === "string";
344
+ }
345
+ function serialize(value) {
346
+ return JSON.stringify(value, null, 2) + "\n";
347
+ }
348
+ function errorMessage(error) {
349
+ return error instanceof Error ? error.message : String(error);
350
+ }