glm-coding-router 1.1.2 → 2.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/LICENSE +21 -21
- package/README.md +542 -426
- package/dist/bin/glm-review.js +28 -3
- package/dist/bin/glm-worker.js +30 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +46 -3
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/doctor-auth.js +107 -0
- package/dist/commands/doctor-command.js +171 -41
- package/dist/commands/landing.js +47 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/status.js +28 -15
- package/dist/commands/usage.js +34 -58
- package/dist/commands/watch.js +289 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/key-inspector.js +45 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process.js +83 -0
- package/dist/core/prompt.js +18 -5
- package/dist/core/routing-flags.js +59 -0
- package/dist/core/user-env.js +17 -7
- package/dist/core/zai-quota.js +148 -0
- package/dist/events/bus.js +64 -0
- package/dist/events/claude-adapter.js +416 -0
- package/dist/events/types.js +9 -0
- package/dist/handoff/bundle.js +203 -0
- package/dist/handoff/parent-handoff.js +48 -0
- package/dist/mcp/server.js +45 -1
- package/dist/routing/glm-routing.js +131 -0
- package/dist/runs/checkpoint.js +204 -0
- package/dist/runs/drain.js +165 -0
- package/dist/runs/heartbeat.js +45 -0
- package/dist/runs/registry.js +350 -0
- package/dist/runs/store.js +186 -0
- package/dist/runs/ulid.js +112 -0
- package/dist/runs/worker-run.js +672 -0
- package/dist/templates/agents-block.js +53 -44
- package/dist/templates/claude-block.js +56 -47
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/command-ui.js +158 -0
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +144 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { logger } from "../core/logging.js";
|
|
4
|
+
/** The one file every run is guaranteed to leave behind, even when it crashed. */
|
|
5
|
+
export const EVENTS_FILE_NAME = "events.jsonl";
|
|
6
|
+
/** `events.jsonl` inside a run directory; exported so every module spells the path the same way. */
|
|
7
|
+
export function eventsFilePath(runDirPath) {
|
|
8
|
+
return path.join(runDirPath, EVENTS_FILE_NAME);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Opens `events.jsonl` in append mode. Append mode is the whole crash story:
|
|
12
|
+
* the file is never truncated, every line reaches the OS as it is appended,
|
|
13
|
+
* and a killed process leaves at worst one half-written final line — which
|
|
14
|
+
* `readEvents` tolerates — and never a summary, which is precisely how
|
|
15
|
+
* `runs show` tells a crashed run from a finished one.
|
|
16
|
+
*
|
|
17
|
+
* `append` may throw (disk full, fd gone); the event bus catches subscriber
|
|
18
|
+
* exceptions, so a broken store degrades to lost lines instead of a dead run.
|
|
19
|
+
*/
|
|
20
|
+
export function openRunStore(runDirPath) {
|
|
21
|
+
fs.mkdirSync(runDirPath, { recursive: true });
|
|
22
|
+
const file = eventsFilePath(runDirPath);
|
|
23
|
+
const fd = fs.openSync(file, "a");
|
|
24
|
+
let closed = false;
|
|
25
|
+
return {
|
|
26
|
+
path: file,
|
|
27
|
+
append(event) {
|
|
28
|
+
if (closed) {
|
|
29
|
+
logger.debug(`run store: append after close on ${file} skipped`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
fs.writeSync(fd, JSON.stringify(event) + "\n", null, "utf8");
|
|
33
|
+
},
|
|
34
|
+
flush() {
|
|
35
|
+
if (!closed) {
|
|
36
|
+
fs.fsyncSync(fd);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
close() {
|
|
40
|
+
if (closed) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
closed = true;
|
|
44
|
+
try {
|
|
45
|
+
fs.fsyncSync(fd);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
fs.closeSync(fd);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reconstructs the event stream from a run directory. A malformed line is
|
|
55
|
+
* skipped, not fatal — a truncated final line is the normal wreckage of a
|
|
56
|
+
* crash, and the events before it are exactly what survived. A missing file
|
|
57
|
+
* yields an empty stream for the same reason: history readers must never
|
|
58
|
+
* throw on the artifacts a crash leaves behind.
|
|
59
|
+
*/
|
|
60
|
+
export function readEvents(runDirPath) {
|
|
61
|
+
let text;
|
|
62
|
+
try {
|
|
63
|
+
text = fs.readFileSync(eventsFilePath(runDirPath), "utf8");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
logger.debug(`run store: no events file in ${runDirPath}`);
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
const events = [];
|
|
70
|
+
const lines = text.split(/\r?\n/);
|
|
71
|
+
for (const [index, line] of lines.entries()) {
|
|
72
|
+
if (line.trim() === "") {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(line);
|
|
77
|
+
if (isEventLike(parsed)) {
|
|
78
|
+
events.push(parsed);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
logger.debug(`run store: line ${index + 1} in ${runDirPath} is not an event`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
logger.debug(`run store: skipping malformed line ${index + 1} in ${runDirPath}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return events;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Rebuilds a summary from events alone. The stream's `RunCompleted` numbers
|
|
92
|
+
* (A0: taken straight from the result message) are authoritative when
|
|
93
|
+
* present; the event-derived fallbacks exist so a crashed run still gets a
|
|
94
|
+
* meaningful summary — that is the entire point of rebuilding rather than
|
|
95
|
+
* trusting a file the crash prevented us from writing.
|
|
96
|
+
*/
|
|
97
|
+
export function summarize(events) {
|
|
98
|
+
let state = "CRASHED";
|
|
99
|
+
let turns = 0;
|
|
100
|
+
let resultTurns = 0;
|
|
101
|
+
let resultDurationMs = 0;
|
|
102
|
+
let durationMs = 0;
|
|
103
|
+
let tokensIn = 0;
|
|
104
|
+
let tokensOut = 0;
|
|
105
|
+
let denied = 0;
|
|
106
|
+
let retries = 0;
|
|
107
|
+
let validation = "none";
|
|
108
|
+
const filesChanged = new Set();
|
|
109
|
+
for (const event of events) {
|
|
110
|
+
switch (event.type) {
|
|
111
|
+
case "TurnStarted":
|
|
112
|
+
turns = Math.max(turns, event.turn);
|
|
113
|
+
break;
|
|
114
|
+
case "FileChanged":
|
|
115
|
+
filesChanged.add(event.path);
|
|
116
|
+
break;
|
|
117
|
+
case "ToolDenied":
|
|
118
|
+
denied += 1;
|
|
119
|
+
if (validation === "pending") {
|
|
120
|
+
validation = "denied";
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
case "ApiRetry":
|
|
124
|
+
retries += 1;
|
|
125
|
+
break;
|
|
126
|
+
case "ValidationStarted":
|
|
127
|
+
validation = "pending";
|
|
128
|
+
break;
|
|
129
|
+
case "ValidationCompleted":
|
|
130
|
+
validation = event.ok ? "ok" : "failed";
|
|
131
|
+
break;
|
|
132
|
+
case "RunCompleted":
|
|
133
|
+
state = "COMPLETED";
|
|
134
|
+
// A run can carry MORE THAN ONE result message: a child that hits
|
|
135
|
+
// --max-turns and continues emits one per segment. Observed live on
|
|
136
|
+
// 2026-09-20 — RunFailed(max_turns), then two RunCompleted — where
|
|
137
|
+
// overwriting made a 21-minute, 64-turn run persist as "88s, 5 turns".
|
|
138
|
+
// Each result describes only its own segment, so accumulate, and let
|
|
139
|
+
// the derived turn count win when it is larger.
|
|
140
|
+
resultTurns += event.turns;
|
|
141
|
+
resultDurationMs += event.durationMs;
|
|
142
|
+
tokensIn += event.tokensIn;
|
|
143
|
+
tokensOut += event.tokensOut;
|
|
144
|
+
break;
|
|
145
|
+
case "RunFailed":
|
|
146
|
+
state = "FAILED";
|
|
147
|
+
break;
|
|
148
|
+
case "RunCancelled":
|
|
149
|
+
state = "CANCELLED";
|
|
150
|
+
break;
|
|
151
|
+
default:
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// The wall clock is the span of the stream; a sum of per-segment result
|
|
156
|
+
// durations misses the gaps between them. Take whichever is larger so a
|
|
157
|
+
// long run can never be reported as a short one.
|
|
158
|
+
const span = events.length > 0
|
|
159
|
+
? Date.parse(events[events.length - 1].ts) - Date.parse(events[0].ts)
|
|
160
|
+
: 0;
|
|
161
|
+
durationMs = Math.max(resultDurationMs, Number.isFinite(span) && span > 0 ? span : 0);
|
|
162
|
+
turns = Math.max(turns, resultTurns);
|
|
163
|
+
return {
|
|
164
|
+
id: events.length > 0 ? events[0].runId : "",
|
|
165
|
+
state,
|
|
166
|
+
turns,
|
|
167
|
+
durationMs,
|
|
168
|
+
filesChanged: [...filesChanged],
|
|
169
|
+
tokensIn,
|
|
170
|
+
tokensOut,
|
|
171
|
+
denied,
|
|
172
|
+
retries,
|
|
173
|
+
validation,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The runtime check is deliberately just "parses and has a `type` string":
|
|
178
|
+
* fully validating the union at runtime would duplicate the event model, and
|
|
179
|
+
* a line that passes this check but carries wrong fields is history written
|
|
180
|
+
* by a future version — dropping it would lose data forever.
|
|
181
|
+
*/
|
|
182
|
+
function isEventLike(value) {
|
|
183
|
+
return (typeof value === "object" &&
|
|
184
|
+
value !== null &&
|
|
185
|
+
typeof value.type === "string");
|
|
186
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* ULID generation (specs/v2-architecture.md, Phase B).
|
|
4
|
+
*
|
|
5
|
+
* Lexicographic sort order equals chronological order — that property, not
|
|
6
|
+
* the id's shape, is why runs use ULIDs instead of UUIDs: the run history is
|
|
7
|
+
* browsed by sorting ids as plain strings.
|
|
8
|
+
*/
|
|
9
|
+
/** Crockford base32: no I, L, O or U, so no visually ambiguous character ever appears in a run id. */
|
|
10
|
+
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
11
|
+
const TIME_LEN = 10;
|
|
12
|
+
const RANDOM_BYTES = 10; // 80 bits = exactly 16 base32 chars × 5 bits
|
|
13
|
+
/**
|
|
14
|
+
* Process-wide monotonic state: the last timestamp used and the random
|
|
15
|
+
* component it was paired with. Two ids in the same millisecond must still
|
|
16
|
+
* order, so the second one increments the first one's randomness instead of
|
|
17
|
+
* drawing fresh bytes.
|
|
18
|
+
*/
|
|
19
|
+
let lastTime = -1;
|
|
20
|
+
let lastRandom = null;
|
|
21
|
+
/**
|
|
22
|
+
* A 26-char ULID: 10 chars of millisecond timestamp (most significant first)
|
|
23
|
+
* followed by 16 chars of randomness. Monotonic within the process — same-ms
|
|
24
|
+
* calls increment the 80-bit random component as a big-endian integer, so
|
|
25
|
+
* generated ids never collide and always sort after their predecessors.
|
|
26
|
+
* `now` is injectable so tests can pin and freeze time.
|
|
27
|
+
*/
|
|
28
|
+
export function ulid(now = Date.now) {
|
|
29
|
+
let time = now();
|
|
30
|
+
let random = null;
|
|
31
|
+
// `<=` rather than `===` also absorbs a clock that jumps backwards: reusing
|
|
32
|
+
// the last timestamp with an incremented random part preserves the invariant
|
|
33
|
+
// that actually matters — generation order == sort order.
|
|
34
|
+
if (lastRandom !== null && time <= lastTime) {
|
|
35
|
+
random = incrementRandom(lastRandom);
|
|
36
|
+
if (random === null) {
|
|
37
|
+
// All 80 random bits overflowed (probability 2^-80). Spin to the next
|
|
38
|
+
// millisecond rather than return a value that sorts before its
|
|
39
|
+
// predecessor; if the clock never advances (a frozen injected clock),
|
|
40
|
+
// step the timestamp ourselves — still strictly after the previous id.
|
|
41
|
+
let spins = 0;
|
|
42
|
+
do {
|
|
43
|
+
time = now();
|
|
44
|
+
} while (time <= lastTime && ++spins < 1_000_000);
|
|
45
|
+
if (time <= lastTime) {
|
|
46
|
+
time = lastTime + 1;
|
|
47
|
+
}
|
|
48
|
+
random = randomBytes(RANDOM_BYTES);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
time = lastTime;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
random = randomBytes(RANDOM_BYTES);
|
|
56
|
+
}
|
|
57
|
+
lastTime = time;
|
|
58
|
+
lastRandom = random;
|
|
59
|
+
return encodeTime(time) + encodeRandom(random);
|
|
60
|
+
}
|
|
61
|
+
/** `run_` + a ULID (doc §5): the prefix makes run ids self-describing in logs, dirs and file names. */
|
|
62
|
+
export function runId(now = Date.now) {
|
|
63
|
+
return `run_${ulid(now)}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A 48-bit millisecond timestamp fits exactly in a double, so plain division
|
|
67
|
+
* encodes it most-significant-first into 10 chars — which is what makes the
|
|
68
|
+
* time part of the string sort chronologically.
|
|
69
|
+
*/
|
|
70
|
+
function encodeTime(time) {
|
|
71
|
+
let chars = "";
|
|
72
|
+
let remaining = time;
|
|
73
|
+
for (let count = 0; count < TIME_LEN; count++) {
|
|
74
|
+
chars = ENCODING[remaining % 32] + chars;
|
|
75
|
+
remaining = Math.floor(remaining / 32);
|
|
76
|
+
}
|
|
77
|
+
return chars;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 10 bytes → 16 × 5-bit symbols, big-endian. 80 is a multiple of 5, so the
|
|
81
|
+
* bit funnel drains exactly with nothing left over.
|
|
82
|
+
*/
|
|
83
|
+
function encodeRandom(bytes) {
|
|
84
|
+
let chars = "";
|
|
85
|
+
let buffer = 0;
|
|
86
|
+
let bits = 0;
|
|
87
|
+
for (const byte of bytes) {
|
|
88
|
+
buffer = (buffer << 8) | byte;
|
|
89
|
+
bits += 8;
|
|
90
|
+
while (bits >= 5) {
|
|
91
|
+
bits -= 5;
|
|
92
|
+
chars += ENCODING[(buffer >>> bits) & 31];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return chars;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* +1 on the 80-bit random component read as a big-endian integer. Returns
|
|
99
|
+
* null when every bit was already 1 — the caller must then move to a new
|
|
100
|
+
* millisecond, because a wrapped value would sort before its predecessor.
|
|
101
|
+
*/
|
|
102
|
+
function incrementRandom(bytes) {
|
|
103
|
+
const next = Uint8Array.from(bytes);
|
|
104
|
+
for (let index = next.length - 1; index >= 0; index--) {
|
|
105
|
+
if (next[index] < 0xff) {
|
|
106
|
+
next[index] += 1;
|
|
107
|
+
return next;
|
|
108
|
+
}
|
|
109
|
+
next[index] = 0;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|