@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.
- package/.claude/commands/explore-case.md +14 -5
- package/.claude/commands/gen-spec.md +12 -5
- package/.claude/commands/heal-spec.md +23 -8
- package/.claude/commands/make-case.md +1 -1
- package/.claude/skills/exploring-cases/SKILL.md +82 -37
- package/.claude/skills/exploring-cases/references/assertion-targets.md +8 -4
- package/.claude/skills/exploring-cases/references/case-rules.md +110 -0
- package/.claude/skills/exploring-cases/references/handoff-card.js +73 -11
- package/.claude/skills/exploring-cases/references/open-questions.md +60 -8
- package/.claude/skills/exploring-cases/references/readiness.md +4 -3
- package/.claude/skills/exploring-cases/references/tools.md +117 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +46 -17
- package/.claude/skills/generating-specs/SKILL.md +148 -55
- package/.claude/skills/generating-specs/references/conventions.md +4 -0
- package/.claude/skills/making-cases/SKILL.md +14 -7
- package/.claude/skills/making-cases/references/gaps.md +7 -4
- package/.claude/skills/making-cases/references/normalizing.md +1 -1
- package/AGENTS.md +68 -20
- package/README.md +8 -6
- package/bin/asrun.mjs +1 -0
- package/dist/lib/case-doc.d.ts +41 -4
- package/dist/lib/case-doc.js +109 -17
- package/dist/scripts/check-cases.js +89 -1
- package/dist/scripts/doctor.js +50 -3
- package/dist/scripts/markers-confirm.d.ts +1 -0
- package/dist/scripts/markers-confirm.js +139 -0
- package/dist/ui/server/actions.js +57 -18
- package/dist/ui/server/app.js +7 -2
- package/dist/ui/server/job-store.d.ts +60 -0
- package/dist/ui/server/job-store.js +198 -0
- package/dist/ui/server/jobs.d.ts +46 -4
- package/dist/ui/server/jobs.js +361 -59
- package/dist/ui/server/process-info.d.ts +17 -0
- package/dist/ui/server/process-info.js +126 -0
- package/dist/ui/server/result-state.d.ts +10 -0
- package/dist/ui/server/result-state.js +106 -0
- package/dist/ui/shared/ai-commands.d.ts +9 -3
- package/dist/ui/shared/ai-commands.js +25 -11
- package/dist/ui/shared/types.d.ts +18 -3
- package/docs/agents/case-format.md +39 -5
- package/docs/ai-workflow.md +80 -21
- package/docs/cases.md +28 -4
- package/docs/commands.md +6 -1
- package/docs/theme-guide.html +93 -5
- package/docs/troubleshooting.md +47 -3
- package/docs/web-ui.md +55 -10
- package/package.json +2 -1
- package/templates/.claude/commands/explore-case.md +14 -4
- package/templates/.claude/commands/gen-spec.md +12 -4
- package/templates/.claude/commands/heal-spec.md +27 -13
- package/templates/.claude/commands/make-case.md +1 -1
- package/templates/AGENTS.md +9 -6
- package/templates/cases/_template.md +7 -0
- package/templates/package.json +1 -0
- package/ui/dist/assets/{dist-ChBHX4rh.js → dist-2msWI-5M.js} +1 -1
- package/ui/dist/assets/{dist-DoChb6QQ.js → dist-4U_gPHXf.js} +1 -1
- package/ui/dist/assets/{dist-CJiwxr1r.js → dist-BMfJatHo.js} +1 -1
- package/ui/dist/assets/{dist-N-o4QIaZ.js → dist-BVvHVJ3N.js} +1 -1
- package/ui/dist/assets/{dist-BC_P1Gnf.js → dist-BmWaNjqO.js} +1 -1
- package/ui/dist/assets/{dist-Do_MhHKF.js → dist-BwNz_xW2.js} +1 -1
- package/ui/dist/assets/{dist-D6jmnDr-.js → dist-J36xayOv.js} +1 -1
- package/ui/dist/assets/{dist-CavmZIv7.js → dist-e-KhTWHJ.js} +1 -1
- package/ui/dist/assets/{dist-BpkTvixI.js → dist-sfZKBSMm.js} +1 -1
- package/ui/dist/assets/{dist-BgPbx2GZ.js → dist-wjB7g6OJ.js} +1 -1
- package/ui/dist/assets/index-CGbRJT3b.js +348 -0
- package/ui/dist/assets/index-CI0i0LWc.css +2 -0
- package/ui/dist/help/case-explore.png +0 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-Cy2l91qV.css +0 -2
- 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
|
|
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 (
|
|
213
|
-
throw new ActionError('Only heal-spec
|
|
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
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
};
|
package/dist/ui/server/app.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|