@cruxy/cli 1.9.0 → 1.11.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 +2 -2
- package/dist/agent/loop.js +16 -1
- package/dist/agent/session.js +73 -9
- package/dist/approval/classify.js +170 -40
- package/dist/approval/prompt.js +52 -6
- package/dist/approval/service.js +1 -11
- package/dist/budget/session-budget.js +10 -1
- package/dist/checkpoint/coverage.js +147 -4
- package/dist/cli/command-catalog.js +5 -1
- package/dist/cli/commands/config.js +18 -3
- package/dist/cli/commands/limits.js +76 -0
- package/dist/cli/commands/logs.js +149 -0
- package/dist/cli/commands/pr.js +11 -11
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +25 -6
- package/dist/cli/commands/sessions.js +181 -0
- package/dist/cli/onboard.js +0 -9
- package/dist/cli/program.js +19 -1
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-commands.js +45 -2
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/manager.js +91 -11
- package/dist/config/schema.js +194 -20
- package/dist/constants.js +12 -2
- package/dist/errors/constructors.js +84 -46
- package/dist/errors/types.js +6 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/log-renderer.js +10 -5
- package/dist/jobs/log-store.js +505 -0
- package/dist/jobs/manager.js +338 -18
- package/dist/mcp/client.js +16 -0
- package/dist/render/limits-report.js +213 -0
- package/dist/render/limits-view.js +125 -0
- package/dist/routing/index.js +1 -1
- package/dist/routing/router.js +34 -14
- package/dist/routing/types.js +0 -2
- package/dist/sandbox/service.js +9 -0
- package/dist/sandbox/types.js +15 -0
- package/dist/session/index.js +3 -1
- package/dist/session/list.js +20 -6
- package/dist/session/log.js +120 -21
- package/dist/session/prune.js +166 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +87 -34
- package/dist/subagent/spawn-tool.js +11 -4
- package/dist/tools/schema-depth.js +18 -0
- package/dist/tui/limits-panel.js +53 -30
- package/dist/usage/collect.js +20 -1
- package/dist/usage/summary.js +48 -1
- package/dist/usage/types.js +52 -0
- package/package.json +2 -2
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { appendFileSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { APP_VERSION } from "../constants.js";
|
|
5
|
+
import { SESSION_FILE_EXT, reservedDir } from "../session/paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* Persisted background-job logs (#172 item 1), under the `subagents/` subtree
|
|
8
|
+
* `paths.ts` reserved for exactly this:
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* ~/.cruxy/projects/<project>/
|
|
12
|
+
* <session-id>.jsonl ← the conversation (P2)
|
|
13
|
+
* subagents/
|
|
14
|
+
* <session-id>.<job-id>.jsonl ← one background job's output (here)
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* ## What this is, and what it is emphatically not
|
|
18
|
+
*
|
|
19
|
+
* It is a POST-MORTEM. It is not "your job survives a restart", and the
|
|
20
|
+
* difference is not pedantry — it is the first bug someone would otherwise
|
|
21
|
+
* file. A job is an in-process {@link ../agent/loop.js} call, `run.ts` calls
|
|
22
|
+
* `cancelAll("session exit")` on the way out, and `orphan.test.ts` pins that
|
|
23
|
+
* this kill-tree's the whole process group. So a job never outlives the session
|
|
24
|
+
* that dispatched it. What this store lets you read afterwards is what the job
|
|
25
|
+
* SAID before it stopped, which is the one thing that used to die with the
|
|
26
|
+
* process — its file mutations already survived as a checkpoint set
|
|
27
|
+
* (`rollback.ts` finds it by `runId === job.id`) and its spend already survived
|
|
28
|
+
* as a usage record keyed by the same id.
|
|
29
|
+
*
|
|
30
|
+
* ## Streamed, never flushed at exit
|
|
31
|
+
*
|
|
32
|
+
* Every line is written when it happens, through the single sink
|
|
33
|
+
* `JobManager.log` already funnels all of them through. The alternative —
|
|
34
|
+
* serialising the ring buffer once on the way out — was rejected twice over:
|
|
35
|
+
*
|
|
36
|
+
* - it would live in the same `finally` that `process.exit` skips. The
|
|
37
|
+
* SIGINT/SIGTERM/SIGHUP handlers in `tui/restore.ts` and
|
|
38
|
+
* `utils/child-tree.ts` both exit the process directly, so a closed terminal
|
|
39
|
+
* window, a dropped ssh session or a plain `kill` runs no `finally` at all.
|
|
40
|
+
* A log that only persists on the orderly path persists exactly the runs
|
|
41
|
+
* that did not need persisting and loses every one that did.
|
|
42
|
+
* - the whole point of the issue is to remove the ring buffer's
|
|
43
|
+
* drop-on-overflow. Writing the BUFFER at exit would persist the truncated
|
|
44
|
+
* tail, i.e. ship the data loss to disk verbatim.
|
|
45
|
+
*
|
|
46
|
+
* ## Durability: the same rules as `session/log.ts`, unchanged
|
|
47
|
+
*
|
|
48
|
+
* One `appendFileSync` of one newline-terminated JSON line, `O_APPEND` so
|
|
49
|
+
* interleaving writers cannot corrupt each other, `0600` from the first line,
|
|
50
|
+
* and a `broken` latch so an unwritable disk goes inert after ONE warning
|
|
51
|
+
* rather than warning per line. A torn final line is skipped by the reader
|
|
52
|
+
* instead of prevented by the writer. Nothing here is temp-then-rename: that
|
|
53
|
+
* makes a whole-file REWRITE atomic, which is the wrong tool for a file that is
|
|
54
|
+
* only ever appended to.
|
|
55
|
+
*/
|
|
56
|
+
/** Extension of a persisted job log. Matches the session log's, deliberately. */
|
|
57
|
+
export const JOB_LOG_EXT = ".jsonl";
|
|
58
|
+
/** Schema version of the `job` header record. */
|
|
59
|
+
export const JOB_LOG_VERSION = 1;
|
|
60
|
+
/**
|
|
61
|
+
* How much of a file's tail is read to find its terminal record.
|
|
62
|
+
*
|
|
63
|
+
* The sweep needs one bit per file — did this job finish, and how — and reading
|
|
64
|
+
* a capped-out 20k-line log in full to learn it would put megabytes of read on
|
|
65
|
+
* the startup path that #255 spent its time removing. The terminal record is
|
|
66
|
+
* the last line by construction (nothing logs into a job after its `finally`,
|
|
67
|
+
* and both `cancelAll` and `abortSiblings` skip jobs that are already
|
|
68
|
+
* terminal), so a few KB always contains it. The tail is SCANNED for the last
|
|
69
|
+
* `end` record rather than assuming the final line is one, so a future writer
|
|
70
|
+
* that appends something after it does not silently turn every finished job
|
|
71
|
+
* into an interrupted one.
|
|
72
|
+
*/
|
|
73
|
+
const TAIL_BYTES = 8192;
|
|
74
|
+
// ── the record shapes ───────────────────────────────────────────────────────
|
|
75
|
+
//
|
|
76
|
+
// `.passthrough()` throughout, matching `session/types.ts`: a newer cruxy's
|
|
77
|
+
// extra fields must never make an older one discard a log it can otherwise
|
|
78
|
+
// read.
|
|
79
|
+
const JobHeaderSchema = z
|
|
80
|
+
.object({
|
|
81
|
+
kind: z.literal("job"),
|
|
82
|
+
version: z.number().int().positive(),
|
|
83
|
+
jobId: z.string(),
|
|
84
|
+
sessionId: z.string(),
|
|
85
|
+
label: z.string(),
|
|
86
|
+
cwd: z.string(),
|
|
87
|
+
startedAt: z.string(),
|
|
88
|
+
cliVersion: z.string().optional(),
|
|
89
|
+
})
|
|
90
|
+
.passthrough();
|
|
91
|
+
const JobLineSchema = z
|
|
92
|
+
.object({
|
|
93
|
+
kind: z.literal("line"),
|
|
94
|
+
atMs: z.number(),
|
|
95
|
+
stream: z.enum(["out", "err"]),
|
|
96
|
+
text: z.string(),
|
|
97
|
+
})
|
|
98
|
+
.passthrough();
|
|
99
|
+
const JobTruncatedSchema = z
|
|
100
|
+
.object({
|
|
101
|
+
kind: z.literal("truncated"),
|
|
102
|
+
atMs: z.number(),
|
|
103
|
+
cap: z.number().int().nonnegative(),
|
|
104
|
+
})
|
|
105
|
+
.passthrough();
|
|
106
|
+
const JobEndSchema = z
|
|
107
|
+
.object({
|
|
108
|
+
kind: z.literal("end"),
|
|
109
|
+
at: z.string(),
|
|
110
|
+
status: z.string(),
|
|
111
|
+
iterations: z.number().int().nonnegative().optional(),
|
|
112
|
+
error: z.string().optional(),
|
|
113
|
+
})
|
|
114
|
+
.passthrough();
|
|
115
|
+
/**
|
|
116
|
+
* A reader's verdict on a job that has no terminal record.
|
|
117
|
+
*
|
|
118
|
+
* NOT a {@link JobStatus}: no job is ever IN this state, and adding it to the
|
|
119
|
+
* lifecycle enum would put a case into every `switch` over a job's real states
|
|
120
|
+
* for a value the manager can never produce. It is what a FILE looks like, not
|
|
121
|
+
* what a job was.
|
|
122
|
+
*/
|
|
123
|
+
export const INTERRUPTED = "interrupted";
|
|
124
|
+
// ── paths ───────────────────────────────────────────────────────────────────
|
|
125
|
+
/** `~/.cruxy/projects/<project>/subagents` */
|
|
126
|
+
export function jobLogsDir(cwd) {
|
|
127
|
+
return reservedDir(cwd, "subagents");
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Filename-safe form of an id. Everything outside `[A-Za-z0-9_-]` collapses to
|
|
131
|
+
* `-`, so a name can never contain a path separator or the `.` that separates
|
|
132
|
+
* the two ids.
|
|
133
|
+
*
|
|
134
|
+
* Like {@link ../session/paths.js projectKey}, this is an INDEX and is not
|
|
135
|
+
* reversible. The true ids are recorded in the header record, which is what a
|
|
136
|
+
* reader reports. Real ids (a UUID session, `job-<n>-<rand>`) are already safe;
|
|
137
|
+
* this exists so a test seam's `idFactory` cannot write outside the subtree.
|
|
138
|
+
*/
|
|
139
|
+
export function idKey(id) {
|
|
140
|
+
const key = id.replace(/[^A-Za-z0-9_-]+/g, "-");
|
|
141
|
+
return key === "" ? "unknown" : key;
|
|
142
|
+
}
|
|
143
|
+
/** `~/.cruxy/projects/<project>/subagents/<session-id>.<job-id>.jsonl` */
|
|
144
|
+
export function jobLogPath(cwd, sessionId, jobId) {
|
|
145
|
+
return path.join(jobLogsDir(cwd), `${idKey(sessionId)}.${idKey(jobId)}${JOB_LOG_EXT}`);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* One background job's on-disk log.
|
|
149
|
+
*
|
|
150
|
+
* ## The per-job cap, and why one is needed at all
|
|
151
|
+
*
|
|
152
|
+
* Retention (#257) bounds how many session-shaped things the tree KEEPS. It
|
|
153
|
+
* cannot bound how big ONE of them gets, and a background job is the first
|
|
154
|
+
* writer here that can produce unbounded output on its own: it runs a full
|
|
155
|
+
* agent loop off screen, and a job stuck in a tool-call loop emits lines for as
|
|
156
|
+
* long as its budget lasts with nobody watching. Streaming it straight to disk
|
|
157
|
+
* with no ceiling is precisely the second unbounded writer under `~/.cruxy`
|
|
158
|
+
* that #257 was filed to prevent — the ring buffer's bound was doing this job
|
|
159
|
+
* implicitly, and taking the buffer off the critical path takes that bound away
|
|
160
|
+
* with it.
|
|
161
|
+
*
|
|
162
|
+
* So the file gets its own: `jobs.logFileLines`, default 20000, floored at
|
|
163
|
+
* `jobs.logBufferLines` because a file that kept LESS than memory would make
|
|
164
|
+
* persisting strictly worse than not persisting.
|
|
165
|
+
*
|
|
166
|
+
* ## The truncation marker is written at the cap, not at the end
|
|
167
|
+
*
|
|
168
|
+
* When the cap is reached a `truncated` record goes down immediately and
|
|
169
|
+
* further lines are dropped. It is not deferred to the terminal record and it
|
|
170
|
+
* does not carry a running count of what came after, for the same reason the
|
|
171
|
+
* rest of this file streams: a marker that only lands on the orderly path is
|
|
172
|
+
* absent from every log that was killed, which is where the truncation is most
|
|
173
|
+
* likely to matter. An exact count of unrecorded lines is not knowable after a
|
|
174
|
+
* SIGKILL either, so promising one would be the lie the marker exists to
|
|
175
|
+
* prevent.
|
|
176
|
+
*
|
|
177
|
+
* The terminal record is written EVEN PAST THE CAP. Suppressing it there would
|
|
178
|
+
* make every capped-out job read as interrupted, which is the one thing the
|
|
179
|
+
* reader's rule must never get wrong.
|
|
180
|
+
*/
|
|
181
|
+
export class JobLogWriter {
|
|
182
|
+
file;
|
|
183
|
+
cap;
|
|
184
|
+
logger;
|
|
185
|
+
/** Set once a write fails: the writer goes inert rather than warning per line. */
|
|
186
|
+
broken = false;
|
|
187
|
+
written = 0;
|
|
188
|
+
truncated = false;
|
|
189
|
+
closed = false;
|
|
190
|
+
/**
|
|
191
|
+
* The header, held until the job records something — the same lazy-meta rule
|
|
192
|
+
* `session/log.ts` adopted in #269, for the same reason: a writer that is
|
|
193
|
+
* constructed and never used should leave nothing on disk. In practice
|
|
194
|
+
* `dispatch` logs immediately, so this flushes on the job's first breath.
|
|
195
|
+
*/
|
|
196
|
+
pendingHeader;
|
|
197
|
+
constructor(opts) {
|
|
198
|
+
this.file = opts.file ?? jobLogPath(opts.cwd, opts.sessionId, opts.jobId);
|
|
199
|
+
this.cap = Math.max(1, Math.floor(opts.cap));
|
|
200
|
+
this.logger = opts.logger;
|
|
201
|
+
this.pendingHeader = {
|
|
202
|
+
kind: "job",
|
|
203
|
+
version: JOB_LOG_VERSION,
|
|
204
|
+
jobId: opts.jobId,
|
|
205
|
+
sessionId: opts.sessionId,
|
|
206
|
+
label: opts.label,
|
|
207
|
+
cwd: opts.cwd,
|
|
208
|
+
startedAt: new Date().toISOString(),
|
|
209
|
+
cliVersion: APP_VERSION,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** Append one log line, honouring the cap. */
|
|
213
|
+
line(line) {
|
|
214
|
+
if (this.broken || this.closed)
|
|
215
|
+
return;
|
|
216
|
+
if (this.written >= this.cap) {
|
|
217
|
+
if (this.truncated)
|
|
218
|
+
return;
|
|
219
|
+
this.truncated = true;
|
|
220
|
+
this.writeRecord({
|
|
221
|
+
kind: "truncated",
|
|
222
|
+
atMs: line.atMs,
|
|
223
|
+
cap: this.cap,
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (this.writeRecord({ kind: "line", ...line }))
|
|
228
|
+
this.written++;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Write the terminal record. Called from the one `finally` that runs on every
|
|
232
|
+
* path a job can actually terminate on — so the presence of this record is
|
|
233
|
+
* exactly the fact {@link readJobLog} reads back. Idempotent.
|
|
234
|
+
*/
|
|
235
|
+
end(status, iterations, error) {
|
|
236
|
+
if (this.broken || this.closed)
|
|
237
|
+
return;
|
|
238
|
+
this.closed = true;
|
|
239
|
+
this.writeRecord({
|
|
240
|
+
kind: "end",
|
|
241
|
+
at: new Date().toISOString(),
|
|
242
|
+
status,
|
|
243
|
+
iterations,
|
|
244
|
+
...(error !== undefined ? { error } : {}),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
writeRecord(record) {
|
|
248
|
+
if (this.broken)
|
|
249
|
+
return false;
|
|
250
|
+
const header = this.pendingHeader;
|
|
251
|
+
if (header !== null) {
|
|
252
|
+
this.pendingHeader = null;
|
|
253
|
+
if (!this.writeLine(header))
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
return this.writeLine(record);
|
|
257
|
+
}
|
|
258
|
+
writeLine(record) {
|
|
259
|
+
try {
|
|
260
|
+
mkdirSync(path.dirname(this.file), { recursive: true });
|
|
261
|
+
// `mode` applies only at creation — 0600 from the first line, matching
|
|
262
|
+
// the session log, credentials, usage and memory.
|
|
263
|
+
appendFileSync(this.file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
this.broken = true;
|
|
268
|
+
this.logger?.warn(`background job log not being saved: ${err.message} ` +
|
|
269
|
+
`(the job continues, and \`/logs\` still reads it from memory)`);
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Persisted job logs for `cwd`'s project, most-recently-modified first.
|
|
276
|
+
*
|
|
277
|
+
* `readdir` plus one `stat` each — no file is opened, exactly as
|
|
278
|
+
* {@link ../session/list.js sessionFilesByRecency} does for sessions, and for
|
|
279
|
+
* the same reason: recency is knowable from the directory alone, so the
|
|
280
|
+
* per-file read only happens for the rows a caller actually shows.
|
|
281
|
+
*
|
|
282
|
+
* The `.jsonl` filter and the `isFile` test are load-bearing, not tidiness:
|
|
283
|
+
* everything returned here is something the sweep will consider deleting, and
|
|
284
|
+
* `statSync` succeeds on a directory. A name that does not split into exactly
|
|
285
|
+
* two ids is skipped rather than guessed at — it was not written by this
|
|
286
|
+
* writer, and the sweep must not delete files it does not own.
|
|
287
|
+
*/
|
|
288
|
+
export function jobLogFilesByRecency(cwd) {
|
|
289
|
+
const dir = jobLogsDir(cwd);
|
|
290
|
+
let names;
|
|
291
|
+
try {
|
|
292
|
+
names = readdirSync(dir);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return []; // no jobs have ever run here — normal, not an error
|
|
296
|
+
}
|
|
297
|
+
const refs = [];
|
|
298
|
+
for (const name of names) {
|
|
299
|
+
if (!name.endsWith(JOB_LOG_EXT))
|
|
300
|
+
continue;
|
|
301
|
+
const stem = name.slice(0, -JOB_LOG_EXT.length);
|
|
302
|
+
const dot = stem.indexOf(".");
|
|
303
|
+
if (dot <= 0 || dot === stem.length - 1)
|
|
304
|
+
continue;
|
|
305
|
+
const sessionKey = stem.slice(0, dot);
|
|
306
|
+
const jobKey = stem.slice(dot + 1);
|
|
307
|
+
if (jobKey.includes("."))
|
|
308
|
+
continue;
|
|
309
|
+
const file = path.join(dir, name);
|
|
310
|
+
try {
|
|
311
|
+
const stat = statSync(file);
|
|
312
|
+
if (!stat.isFile())
|
|
313
|
+
continue;
|
|
314
|
+
refs.push({
|
|
315
|
+
file,
|
|
316
|
+
sessionKey,
|
|
317
|
+
jobKey,
|
|
318
|
+
mtimeMs: stat.mtimeMs,
|
|
319
|
+
size: stat.size,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
continue; // deleted mid-listing
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
refs.sort((a, b) => b.mtimeMs - a.mtimeMs || a.file.localeCompare(b.file));
|
|
327
|
+
return refs;
|
|
328
|
+
}
|
|
329
|
+
/** Refs whose job id equals `id` or begins with it (the `--resume` rule). */
|
|
330
|
+
export function matchJobLogs(refs, id) {
|
|
331
|
+
const exact = refs.filter((r) => r.jobKey === id);
|
|
332
|
+
if (exact.length > 0)
|
|
333
|
+
return exact;
|
|
334
|
+
return refs.filter((r) => r.jobKey.startsWith(id));
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Read one persisted log back.
|
|
338
|
+
*
|
|
339
|
+
* ## RECONCILIATION BY ABSENCE — the whole rule, and there is no other
|
|
340
|
+
*
|
|
341
|
+
* A job is {@link INTERRUPTED} if and only if its file carries no `end`
|
|
342
|
+
* record. `JobManager.execute`'s `finally` writes one on every path that runs
|
|
343
|
+
* at all; the paths that do not run are precisely the ones that killed the
|
|
344
|
+
* process without warning. So "no terminal record" means "the process died
|
|
345
|
+
* mid-job", every time, with nothing to maintain.
|
|
346
|
+
*
|
|
347
|
+
* WHAT THIS REPLACES, and why neither alternative works here:
|
|
348
|
+
*
|
|
349
|
+
* - A PID + start-time liveness marker is category-wrong. A job is not a
|
|
350
|
+
* process — it is an in-process `runAgent` call — so the pid on disk would
|
|
351
|
+
* be the CLI's, and "that pid is alive" only ever tells you the SESSION is
|
|
352
|
+
* alive, which every caller already knows more cheaply. It also adds
|
|
353
|
+
* writable state that a crash can leave stale, which is the disease and not
|
|
354
|
+
* the cure.
|
|
355
|
+
* - A STARTUP SWEEP that rewrites non-terminal logs cannot be the load-bearing
|
|
356
|
+
* rule, because a reader can open a log before any restart happens — `cruxy
|
|
357
|
+
* logs` from a second terminal while the writing session is still running.
|
|
358
|
+
* It would see a `running` job and have nothing to distinguish that from a
|
|
359
|
+
* stale one. Deriving from absence answers the live case and the dead case
|
|
360
|
+
* with the same sentence.
|
|
361
|
+
*
|
|
362
|
+
* Tolerant throughout, matching `replay.ts`: a torn or unknown line is skipped
|
|
363
|
+
* rather than failing the read, because a log that was killed mid-write is the
|
|
364
|
+
* exact case this function exists to serve. Returns null only when the file has
|
|
365
|
+
* no readable header — that is not a log this writer produced.
|
|
366
|
+
*/
|
|
367
|
+
export function readJobLog(file) {
|
|
368
|
+
let raw;
|
|
369
|
+
try {
|
|
370
|
+
raw = readFileSync(file, "utf8");
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
let header = null;
|
|
376
|
+
const lines = [];
|
|
377
|
+
let end = null;
|
|
378
|
+
let truncatedAt;
|
|
379
|
+
for (const text of raw.split("\n")) {
|
|
380
|
+
if (text.trim() === "")
|
|
381
|
+
continue;
|
|
382
|
+
let json;
|
|
383
|
+
try {
|
|
384
|
+
json = JSON.parse(text);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
continue; // torn line — skip, same tolerance as replay
|
|
388
|
+
}
|
|
389
|
+
if (header === null) {
|
|
390
|
+
const h = JobHeaderSchema.safeParse(json);
|
|
391
|
+
if (h.success)
|
|
392
|
+
header = h.data;
|
|
393
|
+
continue; // nothing before the header is ours
|
|
394
|
+
}
|
|
395
|
+
const line = JobLineSchema.safeParse(json);
|
|
396
|
+
if (line.success) {
|
|
397
|
+
lines.push({
|
|
398
|
+
atMs: line.data.atMs,
|
|
399
|
+
stream: line.data.stream,
|
|
400
|
+
text: line.data.text,
|
|
401
|
+
});
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const trunc = JobTruncatedSchema.safeParse(json);
|
|
405
|
+
if (trunc.success) {
|
|
406
|
+
truncatedAt = trunc.data.cap;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const e = JobEndSchema.safeParse(json);
|
|
410
|
+
if (e.success)
|
|
411
|
+
end = e.data;
|
|
412
|
+
}
|
|
413
|
+
if (header === null)
|
|
414
|
+
return null;
|
|
415
|
+
return {
|
|
416
|
+
jobId: header.jobId,
|
|
417
|
+
sessionId: header.sessionId,
|
|
418
|
+
label: header.label,
|
|
419
|
+
cwd: header.cwd,
|
|
420
|
+
startedAt: header.startedAt,
|
|
421
|
+
status: terminalStatus(end),
|
|
422
|
+
...(end?.at !== undefined ? { endedAt: end.at } : {}),
|
|
423
|
+
...(end?.iterations !== undefined ? { iterations: end.iterations } : {}),
|
|
424
|
+
...(end?.error !== undefined ? { error: end.error } : {}),
|
|
425
|
+
lines,
|
|
426
|
+
...(truncatedAt !== undefined ? { truncatedAt } : {}),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The terminal status of a log WITHOUT reading it in full — the tail only.
|
|
431
|
+
*
|
|
432
|
+
* This is what the sweep asks of every file it considers, so it must not cost a
|
|
433
|
+
* full read: see {@link TAIL_BYTES}. Returns {@link INTERRUPTED} when no `end`
|
|
434
|
+
* record is in the tail, which for a file this writer produced means there is
|
|
435
|
+
* none at all.
|
|
436
|
+
*/
|
|
437
|
+
export function readJobLogTerminal(file) {
|
|
438
|
+
let tail;
|
|
439
|
+
try {
|
|
440
|
+
tail = readTail(file, TAIL_BYTES);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return INTERRUPTED;
|
|
444
|
+
}
|
|
445
|
+
let end = null;
|
|
446
|
+
for (const text of tail.split("\n")) {
|
|
447
|
+
if (text.trim() === "")
|
|
448
|
+
continue;
|
|
449
|
+
let json;
|
|
450
|
+
try {
|
|
451
|
+
json = JSON.parse(text);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
continue; // the first line of a tail read is torn by definition
|
|
455
|
+
}
|
|
456
|
+
const e = JobEndSchema.safeParse(json);
|
|
457
|
+
if (e.success)
|
|
458
|
+
end = e.data;
|
|
459
|
+
}
|
|
460
|
+
return terminalStatus(end);
|
|
461
|
+
}
|
|
462
|
+
/** The `end` record's status, narrowed — or {@link INTERRUPTED} when absent. */
|
|
463
|
+
function terminalStatus(end) {
|
|
464
|
+
if (end === null)
|
|
465
|
+
return INTERRUPTED;
|
|
466
|
+
// The status is read back as a plain string and narrowed here rather than
|
|
467
|
+
// parsed as an enum, so a log written by a build that knows a status this one
|
|
468
|
+
// does not is reported as written instead of being discarded.
|
|
469
|
+
return end.status;
|
|
470
|
+
}
|
|
471
|
+
/** The last `bytes` of a file, decoded as UTF-8. Short files read whole. */
|
|
472
|
+
function readTail(file, bytes) {
|
|
473
|
+
const fd = openSync(file, "r");
|
|
474
|
+
try {
|
|
475
|
+
const size = fstatSync(fd).size;
|
|
476
|
+
const length = Math.min(size, bytes);
|
|
477
|
+
const buf = Buffer.alloc(length);
|
|
478
|
+
readSync(fd, buf, 0, length, size - length);
|
|
479
|
+
return buf.toString("utf8");
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
closeSync(fd);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* The {@link idKey}s of every session file currently in `cwd`'s project.
|
|
487
|
+
*
|
|
488
|
+
* KEYS AND NOT IDS, because that is the only comparison that is safe in both
|
|
489
|
+
* directions. A log's filename carries a sanitized session id, so comparing it
|
|
490
|
+
* against a raw session id would mismatch for any id `idKey` altered — and a
|
|
491
|
+
* mismatch here does not spare a file, it DELETES one, by concluding the owning
|
|
492
|
+
* session is gone. Sanitizing both sides makes the two agree by construction.
|
|
493
|
+
* (For a real session id — a UUID — `idKey` is the identity function; this
|
|
494
|
+
* matters only for a test seam or a future id format.)
|
|
495
|
+
*
|
|
496
|
+
* Derived from the same scan `prune.ts` already ran, so the sweep adds no
|
|
497
|
+
* second directory walk over the session files.
|
|
498
|
+
*/
|
|
499
|
+
export function sessionKeysPresent(files) {
|
|
500
|
+
const keys = new Set();
|
|
501
|
+
for (const { file } of files) {
|
|
502
|
+
keys.add(idKey(path.basename(file, SESSION_FILE_EXT)));
|
|
503
|
+
}
|
|
504
|
+
return keys;
|
|
505
|
+
}
|