@asrunjs/asrun 0.8.0 → 0.10.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 (70) hide show
  1. package/.claude/commands/explore-case.md +14 -5
  2. package/.claude/commands/gen-spec.md +12 -5
  3. package/.claude/commands/heal-spec.md +23 -8
  4. package/.claude/commands/make-case.md +1 -1
  5. package/.claude/skills/exploring-cases/SKILL.md +82 -37
  6. package/.claude/skills/exploring-cases/references/assertion-targets.md +8 -4
  7. package/.claude/skills/exploring-cases/references/case-rules.md +110 -0
  8. package/.claude/skills/exploring-cases/references/handoff-card.js +73 -11
  9. package/.claude/skills/exploring-cases/references/open-questions.md +60 -8
  10. package/.claude/skills/exploring-cases/references/readiness.md +4 -3
  11. package/.claude/skills/exploring-cases/references/tools.md +117 -0
  12. package/.claude/skills/exploring-cases/references/user-handoff.md +46 -17
  13. package/.claude/skills/generating-specs/SKILL.md +148 -55
  14. package/.claude/skills/generating-specs/references/conventions.md +4 -0
  15. package/.claude/skills/making-cases/SKILL.md +14 -7
  16. package/.claude/skills/making-cases/references/gaps.md +7 -4
  17. package/.claude/skills/making-cases/references/normalizing.md +1 -1
  18. package/AGENTS.md +68 -20
  19. package/README.md +8 -6
  20. package/bin/asrun.mjs +1 -0
  21. package/dist/lib/case-doc.d.ts +41 -4
  22. package/dist/lib/case-doc.js +109 -17
  23. package/dist/scripts/check-cases.js +89 -1
  24. package/dist/scripts/doctor.js +50 -3
  25. package/dist/scripts/markers-confirm.d.ts +1 -0
  26. package/dist/scripts/markers-confirm.js +139 -0
  27. package/dist/ui/server/actions.js +57 -18
  28. package/dist/ui/server/app.js +7 -2
  29. package/dist/ui/server/job-store.d.ts +60 -0
  30. package/dist/ui/server/job-store.js +198 -0
  31. package/dist/ui/server/jobs.d.ts +46 -4
  32. package/dist/ui/server/jobs.js +361 -59
  33. package/dist/ui/server/process-info.d.ts +17 -0
  34. package/dist/ui/server/process-info.js +126 -0
  35. package/dist/ui/server/result-state.d.ts +10 -0
  36. package/dist/ui/server/result-state.js +106 -0
  37. package/dist/ui/shared/ai-commands.d.ts +9 -3
  38. package/dist/ui/shared/ai-commands.js +25 -11
  39. package/dist/ui/shared/types.d.ts +18 -3
  40. package/docs/agents/case-format.md +39 -5
  41. package/docs/ai-workflow.md +80 -21
  42. package/docs/cases.md +28 -4
  43. package/docs/commands.md +6 -1
  44. package/docs/theme-guide.html +93 -5
  45. package/docs/troubleshooting.md +47 -3
  46. package/docs/web-ui.md +55 -10
  47. package/package.json +2 -1
  48. package/templates/.claude/commands/explore-case.md +14 -4
  49. package/templates/.claude/commands/gen-spec.md +12 -4
  50. package/templates/.claude/commands/heal-spec.md +27 -13
  51. package/templates/.claude/commands/make-case.md +1 -1
  52. package/templates/AGENTS.md +9 -6
  53. package/templates/cases/_template.md +7 -0
  54. package/templates/package.json +1 -0
  55. package/ui/dist/assets/{dist-ChBHX4rh.js → dist-2msWI-5M.js} +1 -1
  56. package/ui/dist/assets/{dist-DoChb6QQ.js → dist-4U_gPHXf.js} +1 -1
  57. package/ui/dist/assets/{dist-CJiwxr1r.js → dist-BMfJatHo.js} +1 -1
  58. package/ui/dist/assets/{dist-N-o4QIaZ.js → dist-BVvHVJ3N.js} +1 -1
  59. package/ui/dist/assets/{dist-BC_P1Gnf.js → dist-BmWaNjqO.js} +1 -1
  60. package/ui/dist/assets/{dist-Do_MhHKF.js → dist-BwNz_xW2.js} +1 -1
  61. package/ui/dist/assets/{dist-D6jmnDr-.js → dist-J36xayOv.js} +1 -1
  62. package/ui/dist/assets/{dist-CavmZIv7.js → dist-e-KhTWHJ.js} +1 -1
  63. package/ui/dist/assets/{dist-BpkTvixI.js → dist-sfZKBSMm.js} +1 -1
  64. package/ui/dist/assets/{dist-BgPbx2GZ.js → dist-wjB7g6OJ.js} +1 -1
  65. package/ui/dist/assets/index-CGbRJT3b.js +348 -0
  66. package/ui/dist/assets/index-CI0i0LWc.css +2 -0
  67. package/ui/dist/help/case-explore.png +0 -0
  68. package/ui/dist/index.html +2 -2
  69. package/ui/dist/assets/index-Cy2l91qV.css +0 -2
  70. package/ui/dist/assets/index-DGQ5logJ.js +0 -338
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CASES_DIR, REPORTS_DIR, ROOT, TESTS_DIR } from '../lib/paths.js';
4
+ import { markerSentence, parseCaseDocument, removeMarkers } from '../lib/case-doc.js';
5
+ /*
6
+ * `pnpm markers:confirm --case <id> --env <env> [--report <run-id|file>] [--dry-run]`
7
+ *
8
+ * Settles the `🔍 to be confirmed` lines of one case from the last run instead of from a browser: a marker goes when
9
+ * every test of the case passed in the newest report AND the spec carries the line as
10
+ * `// unconfirmed: <the sentence>`. It answers "does the page still say this?", never "is this the right
11
+ * expectation": that stays a person's decision, which the case panel shows as "why it needs you".
12
+ *
13
+ * A report is evidence for the wording it asserted, not for a line no assertion reads, so a marker without an
14
+ * `// unconfirmed:` line in the spec is left alone and named in the output.
15
+ */
16
+ const RUN_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(-[a-z0-9]+(-[a-z0-9]+)*)?$/;
17
+ const UNCONFIRMED = /\/\/[ \t]*unconfirmed:[ \t]*(.+)$/gm;
18
+ /** Shorter matches are noise: a two-word sentence would match half a spec. */
19
+ const MIN_MATCH = 16;
20
+ const args = process.argv.slice(2);
21
+ const option = (name) => {
22
+ const index = args.indexOf(name);
23
+ return index >= 0 ? args[index + 1] : undefined;
24
+ };
25
+ const caseId = option('--case');
26
+ const env = option('--env');
27
+ const reportArg = option('--report');
28
+ const dryRun = args.includes('--dry-run');
29
+ if (args.includes('--help') || !caseId || !env) {
30
+ console.log('Usage: pnpm markers:confirm --case <case-id> --env <env> [--report <run-id|file>] [--dry-run]');
31
+ console.log('Removes the "to be confirmed" markers of one case when that run passed and the spec checks them.');
32
+ process.exit(args.includes('--help') ? 0 : 1);
33
+ }
34
+ /** The report to trust: the one named by `--report` (a run id or a summary file), else the newest run. */
35
+ function chosenSummary() {
36
+ if (reportArg === undefined)
37
+ return newestSummary();
38
+ const file = reportArg.endsWith('.json')
39
+ ? path.resolve(ROOT, reportArg)
40
+ : path.join(REPORTS_DIR, reportArg, 'summary.json');
41
+ if (!fs.existsSync(file))
42
+ return null;
43
+ return { where: path.relative(ROOT, file).replace(/\\/g, '/'), summary: JSON.parse(fs.readFileSync(file, 'utf8')) };
44
+ }
45
+ /** The newest report: `latest/` when it exists, otherwise the newest run folder that has a summary. */
46
+ function newestSummary() {
47
+ const latest = path.join(REPORTS_DIR, 'latest', 'summary.json');
48
+ if (fs.existsSync(latest))
49
+ return { where: 'reports/latest/summary.json', summary: JSON.parse(fs.readFileSync(latest, 'utf8')) };
50
+ if (!fs.existsSync(REPORTS_DIR))
51
+ return null;
52
+ const runs = fs
53
+ .readdirSync(REPORTS_DIR)
54
+ .filter((name) => RUN_ID.test(name) && fs.existsSync(path.join(REPORTS_DIR, name, 'summary.json')))
55
+ .sort();
56
+ const last = runs.at(-1);
57
+ if (!last)
58
+ return null;
59
+ return {
60
+ where: `reports/${last}/summary.json`,
61
+ summary: JSON.parse(fs.readFileSync(path.join(REPORTS_DIR, last, 'summary.json'), 'utf8')),
62
+ };
63
+ }
64
+ const normalize = (value) => value
65
+ .replace(/`/g, '')
66
+ .replace(/[ \t]{2,}/g, ' ')
67
+ .replace(/[.;:,]+$/, '')
68
+ .trim()
69
+ .toLowerCase();
70
+ const casePath = path.join(CASES_DIR, `${caseId}.md`);
71
+ const specPath = path.join(TESTS_DIR, `${caseId}.spec.ts`);
72
+ if (!fs.existsSync(casePath)) {
73
+ console.error(`FAIL ${caseId}: no case document at cases/${caseId}.md`);
74
+ process.exit(1);
75
+ }
76
+ const report = chosenSummary();
77
+ if (report === null) {
78
+ console.error('FAIL: no run report found. Run the case first: pnpm asrun --env ' + env + ' --grep ' + caseId);
79
+ process.exit(1);
80
+ }
81
+ const results = report.summary.results.filter((result) => result.caseId === caseId);
82
+ if (results.length === 0) {
83
+ console.error(`FAIL ${caseId}: ${report.where} holds no result for this case. Run it first.`);
84
+ process.exit(1);
85
+ }
86
+ const runEnv = report.summary.env ?? results.find((result) => result.env !== undefined)?.env;
87
+ if (runEnv !== undefined && runEnv !== env) {
88
+ console.error(`FAIL ${caseId}: the newest report is a ${runEnv} run, not ${env}. Run the case in ${env} first.`);
89
+ process.exit(1);
90
+ }
91
+ const unsettled = results.filter((result) => result.status !== 'passed');
92
+ if (unsettled.length > 0) {
93
+ console.error(`FAIL ${caseId}: ${unsettled.length} of ${results.length} test(s) did not pass in ${report.where} (${unsettled
94
+ .map((result) => `${result.profile}: ${result.status}`)
95
+ .join(', ')}); nothing is confirmed from a run that did not pass.`);
96
+ process.exit(1);
97
+ }
98
+ const text = fs.readFileSync(casePath, 'utf8');
99
+ const doc = parseCaseDocument(text);
100
+ if (doc.markers.length === 0) {
101
+ console.log(`${caseId}: no marker to settle.`);
102
+ process.exit(0);
103
+ }
104
+ if (!fs.existsSync(specPath)) {
105
+ console.log(`${caseId}: no spec yet (tests/${caseId}.spec.ts), so no run can confirm a marker.`);
106
+ process.exit(0);
107
+ }
108
+ const spec = fs.readFileSync(specPath, 'utf8');
109
+ const quoted = [...spec.matchAll(UNCONFIRMED)].map((match) => normalize(match[1]));
110
+ const confirmed = [];
111
+ const left = [];
112
+ for (const marker of doc.markers) {
113
+ const sentence = normalize(markerSentence(text, marker));
114
+ const match = sentence.length >= MIN_MATCH
115
+ ? quoted.find((quote) => quote.includes(sentence) || sentence.includes(quote))
116
+ : undefined;
117
+ if (match === undefined) {
118
+ left.push({ marker, reason: 'the spec has no "// unconfirmed:" line for it, so no assertion reads it' });
119
+ continue;
120
+ }
121
+ confirmed.push(marker);
122
+ }
123
+ for (const { marker, reason } of left) {
124
+ console.log(`keep ${caseId}:${marker.line} ${markerSentence(text, marker).slice(0, 100)}`);
125
+ console.log(` ${reason}`);
126
+ }
127
+ if (confirmed.length === 0) {
128
+ console.log(`${caseId}: nothing to settle from ${report.where}.`);
129
+ process.exit(0);
130
+ }
131
+ for (const marker of confirmed) {
132
+ console.log(`pass ${caseId}:${marker.line} ${markerSentence(text, marker).slice(0, 100)}`);
133
+ }
134
+ if (dryRun) {
135
+ console.log(`dry run: ${confirmed.length} marker(s) would go, ${left.length} would stay.`);
136
+ process.exit(0);
137
+ }
138
+ fs.writeFileSync(casePath, removeMarkers(text, confirmed));
139
+ console.log(`wrote cases/${caseId}.md: ${confirmed.length} marker(s) settled by ${report.where}, ${left.length} left.`);
@@ -16,6 +16,21 @@ import { AI_COMMANDS, AI_COMMAND_WRITES, AI_TOOLS, continueCommandLine, continue
16
16
  const REPORTS_DIR = path.join(ROOT, 'reports');
17
17
  const REPORT_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(-[a-z0-9]+(-[a-z0-9]+)*)?$/;
18
18
  const newSessionId = (tool) => tool === 'claude' ? crypto.randomUUID() : `ses_${crypto.randomBytes(12).toString('hex')}`;
19
+ /**
20
+ * The request as the person made it, for "Run again". A confirmation they gave is left out on purpose: write cases and
21
+ * an overwritten spec are asked about again on the next run.
22
+ */
23
+ function replayRequest(request) {
24
+ if (request.action === 'run') {
25
+ const { confirmWritesIn: _confirmed, ...rest } = request;
26
+ return rest;
27
+ }
28
+ if (request.action === 'ai') {
29
+ const { force: _force, ...rest } = request;
30
+ return rest;
31
+ }
32
+ return request;
33
+ }
19
34
  export const startJobSchema = z.discriminatedUnion('action', [
20
35
  z.object({ action: z.literal('signin'), profile: z.string().min(1), env: z.string().min(1) }),
21
36
  z.object({ action: z.literal('visit'), profile: z.string().min(1), env: z.string().min(1) }),
@@ -157,6 +172,7 @@ export function buildJobSpec(request) {
157
172
  args: [...scriptArgs('login.ts'), request.profile],
158
173
  locks: [authLock(request.profile)],
159
174
  acceptsInput: true,
175
+ rerun: replayRequest(request),
160
176
  onFinish: () => void verifyProfiles({ ids: [request.profile] }),
161
177
  };
162
178
  }
@@ -175,16 +191,20 @@ export function buildJobSpec(request) {
175
191
  args: [...scriptArgs('explore.ts'), request.profile, ...(startPath ? [startPath] : [])],
176
192
  locks: [`visit:${request.profile}`],
177
193
  acceptsInput: false,
194
+ rerun: replayRequest(request),
178
195
  };
179
196
  }
180
197
  // An AI run on a case, a first turn or a continuation: the same permissions and locks either way.
181
198
  // Generating and healing write the spec and the case values and run the tests, so they must not overlap with a run.
182
- // Reviewing only edits the case file and looks at pages, so it takes just the case lock.
199
+ // Reviewing only edits the case file, but every AI command drives the browser through the workspace's one
200
+ // `playwright-test` MCP server: two runs at once interleave on it and leave it wedged for every later run (one
201
+ // stops the other's paused test, and its call queue never drains). So the `ai` lock is workspace-wide, and only
202
+ // the case lock narrows it further.
183
203
  const aiSpec = (tool, command, caseId, env, sessionId, run, model, thinking) => ({
184
204
  kind: 'ai',
185
205
  env,
186
206
  ...run,
187
- locks: [`ai:${caseId}`, ...(AI_COMMAND_WRITES[command] === 'files' ? ['run', ...allLocks()] : [])],
207
+ locks: ['ai', `ai:${caseId}`, ...(AI_COMMAND_WRITES[command] === 'files' ? ['run', ...allLocks()] : [])],
188
208
  acceptsInput: false,
189
209
  stream: tool,
190
210
  ai: { tool, command, caseId, env, sessionId, ...(model && { model }), ...(thinking && { thinking }) },
@@ -209,15 +229,20 @@ export function buildJobSpec(request) {
209
229
  if (request.attended && request.command !== 'explore-case') {
210
230
  throw new ActionError('Only explore-case can be attended.');
211
231
  }
212
- if ((request.profiles || request.hints !== undefined || request.report) && request.command !== 'heal-spec') {
213
- throw new ActionError('Only heal-spec takes profiles, hints and a report.');
232
+ if (request.report && request.command !== 'heal-spec') {
233
+ throw new ActionError('Only heal-spec reads a report.');
234
+ }
235
+ // The spec is generated for every profile the case names (and runs for every profile), so a selection could only
236
+ // narrow it wrongly.
237
+ if (request.profiles && request.command === 'gen-spec') {
238
+ throw new ActionError('gen-spec takes no profiles: it uses every profile the case holds information for.');
214
239
  }
215
240
  const profilesInEnv = listProfiles(request.env, knownProfiles).map((profile) => profile.id);
216
241
  if (request.profiles?.some((id) => !profilesInEnv.includes(id))) {
217
242
  throw new ActionError(`Unknown profile for "${request.env}".`);
218
243
  }
219
244
  if (request.profiles?.length === 0)
220
- throw new ActionError('Choose at least one profile to heal.');
245
+ throw new ActionError('Choose at least one profile.');
221
246
  if (request.report !== undefined && !(REPORT_ID.test(request.report) && fs.existsSync(path.join(REPORTS_DIR, request.report, 'summary.json')))) {
222
247
  throw new ActionError(`Unknown report "${request.report}".`);
223
248
  }
@@ -234,25 +259,33 @@ export function buildJobSpec(request) {
234
259
  thinking: request.thinking,
235
260
  force: request.force,
236
261
  attended: request.attended,
237
- // Naming every profile is the same as leaving it out: the agent then takes all that are logged in.
262
+ // Naming every profile of the environment is the same as leaving it out: the agent then takes all that are logged in.
238
263
  profiles: request.profiles && request.profiles.length < profilesInEnv.length ? request.profiles : undefined,
239
264
  hints: hintsPath,
240
265
  report: request.report,
241
266
  };
242
267
  const session = { id: newSessionId(request.tool), resume: false };
243
268
  const { file, args } = headlessInvocation(request.tool, request.command, request.caseId, options, session);
244
- return aiSpec(request.tool, request.command, request.caseId, request.env, session.id, {
245
- command: headlessCommandLine(request.tool, request.command, request.caseId, options, session),
246
- file,
247
- args,
248
- }, request.model, request.thinking);
269
+ return {
270
+ ...aiSpec(request.tool, request.command, request.caseId, request.env, session.id, {
271
+ command: headlessCommandLine(request.tool, request.command, request.caseId, options, session),
272
+ file,
273
+ args,
274
+ }, request.model, request.thinking),
275
+ rerun: replayRequest(request),
276
+ };
249
277
  }
250
278
  if (request.action === 'continue') {
251
279
  const earlier = jobs.get(request.jobId);
252
280
  const ai = earlier?.spec.ai;
253
281
  if (!earlier || !ai)
254
282
  throw new ActionError('Unknown job, or it was not an AI run on a case.');
255
- if (earlier.info.status !== 'succeeded' || earlier.info.resultState !== 'needs-input') {
283
+ const finishedWithQuestions = earlier.info.status === 'succeeded' && earlier.info.resultState === 'needs-input';
284
+ // A server restart can leave a run that had already written its questions: answering them is still the next step.
285
+ const recoveredWithQuestions = earlier.info.status === 'interrupted' &&
286
+ earlier.info.resultState === 'needs-input' &&
287
+ earlier.info.leftover?.state !== 'alive';
288
+ if (!finishedWithQuestions && !recoveredWithQuestions) {
256
289
  throw new ActionError('Only a run that finished with RESULT: needs-input can be continued.');
257
290
  }
258
291
  if (findContinuation(jobs.list(), earlier.info.id)) {
@@ -267,12 +300,16 @@ export function buildJobSpec(request) {
267
300
  fs.writeFileSync(path.join(ROOT, replyPath), request.reply.trim() + '\n');
268
301
  // The next turn keeps how the first one was told to run (model and reasoning), so the log stays the same.
269
302
  const { file, args } = continueInvocation(ai.tool, ai.command, replyPath, ai.sessionId, ai.model, ai.thinking);
270
- return aiSpec(ai.tool, ai.command, ai.caseId, ai.env, ai.sessionId, {
271
- command: continueCommandLine(ai.tool, ai.command, replyPath, ai.sessionId, ai.model, ai.thinking),
272
- file,
273
- args,
274
- continuesJobId: earlier.info.id,
275
- }, ai.model, ai.thinking);
303
+ return {
304
+ ...aiSpec(ai.tool, ai.command, ai.caseId, ai.env, ai.sessionId, {
305
+ command: continueCommandLine(ai.tool, ai.command, replyPath, ai.sessionId, ai.model, ai.thinking),
306
+ file,
307
+ args,
308
+ continuesJobId: earlier.info.id,
309
+ }, ai.model, ai.thinking),
310
+ // "Run again" on a continued run repeats the run it continued, not the answers that were sent to it.
311
+ ...(earlier.spec.rerun && { rerun: earlier.spec.rerun }),
312
+ };
276
313
  }
277
314
  if (request.action === 'make-cases') {
278
315
  if (request.memo.trim() === '')
@@ -290,6 +327,7 @@ export function buildJobSpec(request) {
290
327
  locks: ['ai:make-cases'],
291
328
  acceptsInput: false,
292
329
  stream: request.tool,
330
+ rerun: replayRequest(request),
293
331
  };
294
332
  }
295
333
  if (!knownEnvs.includes(request.env))
@@ -359,6 +397,7 @@ export function buildJobSpec(request) {
359
397
  reportId,
360
398
  locks: ['run', ...allLocks()],
361
399
  acceptsInput: false,
400
+ rerun: replayRequest(request),
362
401
  ...(updateBaselines.length > 0 && { baselineCases: updateBaselines }),
363
402
  onFinish: () => void verifyProfiles({ env: request.env }),
364
403
  };
@@ -111,7 +111,7 @@ export function createApp({ port }) {
111
111
  const assertProfileIdle = (id) => {
112
112
  const busy = jobs
113
113
  .list()
114
- .find((job) => job.status === 'running' &&
114
+ .find((job) => jobs.holdsLock(job) &&
115
115
  (job.kind === 'run' ||
116
116
  job.kind === 'ai' ||
117
117
  (job.profile === id && (job.kind === 'signin' || job.kind === 'visit'))));
@@ -157,7 +157,7 @@ export function createApp({ port }) {
157
157
  api.get('/case-impact/:id{.+}', (c) => c.json(caseImpact(c.req.param('id'))));
158
158
  // Moving or deleting files under a running agent or test run would break it.
159
159
  const assertNoRunningWork = () => {
160
- const busy = jobs.list().find((job) => job.status === 'running' && (job.kind === 'ai' || job.kind === 'run'));
160
+ const busy = jobs.list().find((job) => jobs.holdsLock(job) && (job.kind === 'ai' || job.kind === 'run'));
161
161
  if (busy)
162
162
  throw new CaseError(`Wait for "${busy.command}" to finish first.`, 409);
163
163
  };
@@ -280,6 +280,11 @@ export function createApp({ port }) {
280
280
  return c.json(await listAiModels(tool, c.req.query('refresh') === '1'));
281
281
  });
282
282
  api.get('/jobs', (c) => c.json(jobs.list()));
283
+ // What "Run again" starts. The stored request has no write-case or overwrite confirmation: the page asks again.
284
+ api.get('/jobs/:id/rerun', (c) => {
285
+ const request = jobs.rerunRequest(c.req.param('id'));
286
+ return request ? c.json({ request }) : c.json({ error: 'This job cannot be started again.' }, 404);
287
+ });
283
288
  api.post('/jobs', async (c) => {
284
289
  const request = startJobSchema.parse(await c.req.json());
285
290
  return c.json(jobs.start(buildJobSpec(request)), 201);
@@ -0,0 +1,60 @@
1
+ import type { AiCommand, AiTool } from '../shared/ai-commands.js';
2
+ import type { JobChunk, JobInfo, StartJobRequest } from '../shared/types.js';
3
+ /** What a job was started with, as far as it has to survive a restart. */
4
+ export interface PersistedJobSpec {
5
+ kind: JobInfo['kind'];
6
+ profile?: string;
7
+ env?: string;
8
+ command: string;
9
+ file: string;
10
+ args: string[];
11
+ locks: string[];
12
+ acceptsInput: boolean;
13
+ stream?: AiTool;
14
+ baselineCases?: string[];
15
+ reportId?: string;
16
+ ai?: {
17
+ tool: AiTool;
18
+ command: AiCommand;
19
+ caseId: string;
20
+ env: string;
21
+ sessionId: string;
22
+ model?: string;
23
+ thinking?: boolean;
24
+ };
25
+ continuesJobId?: string;
26
+ /** What "Run again" starts: the person's request, without the confirmations they gave. */
27
+ rerun?: StartJobRequest;
28
+ /** The file the command resolved to on PATH, kept so a process that outlived the server can be recognized. */
29
+ spawnedPath?: string;
30
+ }
31
+ /** One job on disk: what the UI shows, how to continue it, and which process to stop after a restart. */
32
+ export interface PersistedJob {
33
+ version: 1;
34
+ info: JobInfo;
35
+ spec: PersistedJobSpec;
36
+ /** The root process that was spawned; absent when it could not be started. */
37
+ pid?: number;
38
+ }
39
+ export declare const jobDir: (id: string) => string;
40
+ export declare const chunksFile: (id: string) => string;
41
+ export declare const logFile: (id: string) => string;
42
+ export declare function ensureJobDir(id: string): void;
43
+ /** Writes the record through a temporary file, so a crash cannot leave half a JSON document behind. */
44
+ export declare function writeRecord(record: PersistedJob): void;
45
+ /** Every record that can still be read, oldest first. A broken one is skipped: its log stays where it is. */
46
+ export declare function readJobs(): PersistedJob[];
47
+ /** The last chunks of a job, within the same memory budget the live job uses. */
48
+ export declare function readChunks(id: string, maxChars: number): {
49
+ chunks: JobChunk[];
50
+ nextSeq: number;
51
+ bufferedChars: number;
52
+ };
53
+ /** Adds a chunk to a job that is not running any more (a note about the restart). */
54
+ export declare function appendChunkNow(id: string, chunk: JobChunk, logText: string): void;
55
+ /**
56
+ * Deletes what is no longer worth keeping: older than the retention window, or beyond the newest `keep` finished jobs.
57
+ * Jobs still running in this server (`live`) and a leftover process that may still be alive are never deleted: that
58
+ * record is the only way to manage them. Flat `.log` files from before records existed are pruned by age.
59
+ */
60
+ export declare function pruneJobs(keep: number, maxAgeMs: number, live?: ReadonlySet<string>): void;
@@ -0,0 +1,198 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ROOT } from '../../lib/paths.js';
4
+ const JOBS_DIR = path.join(ROOT, '.tmp', 'ui-jobs');
5
+ const RECORD_FILE = 'record.json';
6
+ const CHUNKS_FILE = 'chunks.jsonl';
7
+ const LOG_FILE = 'output.log';
8
+ /** A temporary record file left behind by a crash is only useful while its writer may still be running. */
9
+ const STALE_TEMP_MS = 60 * 60 * 1000;
10
+ // Job ids are `crypto.randomBytes(6).toString('hex')`; the pattern also keeps a stray folder out of the paths below.
11
+ const JOB_ID = /^[a-f0-9]{8,32}$/;
12
+ /** How much of a chunks file is read back into memory; the full text stays in output.log. */
13
+ const MAX_TAIL_BYTES = 8 * 1024 * 1024;
14
+ export const jobDir = (id) => path.join(JOBS_DIR, id);
15
+ export const chunksFile = (id) => path.join(jobDir(id), CHUNKS_FILE);
16
+ export const logFile = (id) => path.join(jobDir(id), LOG_FILE);
17
+ export function ensureJobDir(id) {
18
+ fs.mkdirSync(jobDir(id), { recursive: true });
19
+ }
20
+ function readRecord(id) {
21
+ try {
22
+ const parsed = JSON.parse(fs.readFileSync(path.join(jobDir(id), RECORD_FILE), 'utf8'));
23
+ if (parsed?.version !== 1 || parsed.info?.id !== id)
24
+ return null;
25
+ return parsed;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** Writes the record through a temporary file, so a crash cannot leave half a JSON document behind. */
32
+ export function writeRecord(record) {
33
+ const target = path.join(jobDir(record.info.id), RECORD_FILE);
34
+ ensureJobDir(record.info.id);
35
+ const temporary = `${target}.${process.pid}.tmp`;
36
+ try {
37
+ fs.writeFileSync(temporary, JSON.stringify(record));
38
+ fs.renameSync(temporary, target);
39
+ }
40
+ catch (error) {
41
+ fs.rmSync(temporary, { force: true });
42
+ throw error;
43
+ }
44
+ }
45
+ /** Every record that can still be read, oldest first. A broken one is skipped: its log stays where it is. */
46
+ export function readJobs() {
47
+ if (!fs.existsSync(JOBS_DIR))
48
+ return [];
49
+ const jobs = [];
50
+ for (const entry of fs.readdirSync(JOBS_DIR, { withFileTypes: true })) {
51
+ if (!entry.isDirectory() || !JOB_ID.test(entry.name))
52
+ continue;
53
+ const record = readRecord(entry.name);
54
+ if (record)
55
+ jobs.push(record);
56
+ else
57
+ console.warn(`Ignoring the job record in ${path.join(JOBS_DIR, entry.name)}: it could not be read.`);
58
+ }
59
+ return jobs.sort((left, right) => left.info.startedAt.localeCompare(right.info.startedAt));
60
+ }
61
+ /** The last chunks of a job, within the same memory budget the live job uses. */
62
+ export function readChunks(id, maxChars) {
63
+ let text = '';
64
+ try {
65
+ const file = chunksFile(id);
66
+ const size = fs.statSync(file).size;
67
+ const length = Math.min(size, Math.max(maxChars * 2, MAX_TAIL_BYTES));
68
+ const start = size - length;
69
+ if (length > 0) {
70
+ const buffer = Buffer.alloc(length);
71
+ const descriptor = fs.openSync(file, 'r');
72
+ try {
73
+ fs.readSync(descriptor, buffer, 0, length, start);
74
+ }
75
+ finally {
76
+ fs.closeSync(descriptor);
77
+ }
78
+ text = buffer.toString('utf8');
79
+ // The tail starts in the middle of a line: that chunk is not valid and is dropped below.
80
+ if (start > 0)
81
+ text = text.slice(text.indexOf('\n') + 1);
82
+ }
83
+ }
84
+ catch {
85
+ return { chunks: [], nextSeq: 0, bufferedChars: 0 };
86
+ }
87
+ const chunks = [];
88
+ let bufferedChars = 0;
89
+ for (const line of text.split('\n')) {
90
+ if (line.trim() === '')
91
+ continue;
92
+ try {
93
+ const chunk = JSON.parse(line);
94
+ if (typeof chunk?.seq !== 'number' || typeof chunk.data !== 'string')
95
+ continue;
96
+ chunks.push(chunk);
97
+ bufferedChars += chunk.data.length;
98
+ }
99
+ catch {
100
+ // A crash can leave the last line half-written; it is not a chunk.
101
+ }
102
+ }
103
+ while (bufferedChars > maxChars && chunks.length > 1) {
104
+ bufferedChars -= chunks.shift().data.length;
105
+ }
106
+ const nextSeq = chunks.reduce((next, chunk) => Math.max(next, chunk.seq + 1), 0);
107
+ return { chunks, nextSeq, bufferedChars };
108
+ }
109
+ /** Adds a chunk to a job that is not running any more (a note about the restart). */
110
+ export function appendChunkNow(id, chunk, logText) {
111
+ ensureJobDir(id);
112
+ fs.appendFileSync(chunksFile(id), `${JSON.stringify(chunk)}\n`);
113
+ fs.appendFileSync(logFile(id), logText);
114
+ }
115
+ /**
116
+ * Deletes what is no longer worth keeping: older than the retention window, or beyond the newest `keep` finished jobs.
117
+ * Jobs still running in this server (`live`) and a leftover process that may still be alive are never deleted: that
118
+ * record is the only way to manage them. Flat `.log` files from before records existed are pruned by age.
119
+ */
120
+ export function pruneJobs(keep, maxAgeMs, live = new Set()) {
121
+ if (!fs.existsSync(JOBS_DIR))
122
+ return;
123
+ const now = Date.now();
124
+ const records = [];
125
+ for (const entry of fs.readdirSync(JOBS_DIR, { withFileTypes: true })) {
126
+ const full = path.join(JOBS_DIR, entry.name);
127
+ if (!entry.isDirectory()) {
128
+ // The log of a job from before this format; no record, so only its age can decide.
129
+ if (/\.log$/i.test(entry.name)) {
130
+ try {
131
+ if (now - fs.statSync(full).mtimeMs > maxAgeMs)
132
+ fs.rmSync(full, { force: true });
133
+ }
134
+ catch {
135
+ // It disappeared while it was being looked at.
136
+ }
137
+ }
138
+ else if (/^record\.json\..+\.tmp$/i.test(entry.name)) {
139
+ try {
140
+ if (now - fs.statSync(full).mtimeMs > STALE_TEMP_MS)
141
+ fs.rmSync(full, { force: true });
142
+ }
143
+ catch {
144
+ // It disappeared while it was being looked at.
145
+ }
146
+ }
147
+ continue;
148
+ }
149
+ if (!JOB_ID.test(entry.name) || live.has(entry.name))
150
+ continue;
151
+ const record = readRecord(entry.name);
152
+ // A directory that never got its record (a crash between creating the folder and writing it) is dropped once it is
153
+ // old enough that no writer can still be filling it.
154
+ if (!record) {
155
+ try {
156
+ if (now - fs.statSync(full).mtimeMs > STALE_TEMP_MS)
157
+ fs.rmSync(full, { recursive: true, force: true });
158
+ }
159
+ catch {
160
+ // It disappeared while it was being looked at.
161
+ }
162
+ continue;
163
+ }
164
+ const leftover = record.info.leftover?.state;
165
+ const endedAt = record.info.endedAt ?? record.info.interruptedAt ?? record.info.startedAt;
166
+ const parsed = endedAt ? Date.parse(endedAt) : Number.NaN;
167
+ let time;
168
+ try {
169
+ time = Number.isFinite(parsed) ? parsed : fs.statSync(full).mtimeMs;
170
+ }
171
+ catch {
172
+ continue;
173
+ }
174
+ // A record from a crashed server has to survive until the next start can mark it; an orphan is pinned for ever,
175
+ // because it is the only way to stop its process.
176
+ const pinned = record.info.status === 'running' ||
177
+ (record.info.status === 'interrupted' && (leftover === 'alive' || leftover === 'unknown'));
178
+ records.push({ id: entry.name, time, pinned });
179
+ }
180
+ records.sort((left, right) => right.time - left.time);
181
+ let kept = 0;
182
+ for (const record of records) {
183
+ if (record.pinned)
184
+ continue;
185
+ const tooOld = now - record.time > maxAgeMs;
186
+ // `kept` counts the newest finished jobs, so the list is cleared from the back.
187
+ const tooMany = kept >= keep;
188
+ kept += 1;
189
+ if (!tooOld && !tooMany)
190
+ continue;
191
+ try {
192
+ fs.rmSync(jobDir(record.id), { recursive: true, force: true });
193
+ }
194
+ catch {
195
+ // A file still open in another process: leave it for the next cleanup.
196
+ }
197
+ }
198
+ }