@asrunjs/asrun 0.9.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 +11 -6
- package/.claude/commands/gen-spec.md +10 -6
- package/.claude/commands/heal-spec.md +13 -9
- package/.claude/commands/make-case.md +1 -1
- package/.claude/skills/exploring-cases/SKILL.md +65 -31
- 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 +58 -6
- 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 +71 -33
- 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 +51 -11
- package/README.md +4 -3
- 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 +50 -16
- 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 +4 -1
- package/dist/ui/shared/ai-commands.js +14 -4
- package/dist/ui/shared/types.d.ts +17 -2
- package/docs/agents/case-format.md +39 -5
- package/docs/ai-workflow.md +47 -15
- package/docs/cases.md +28 -4
- package/docs/commands.md +6 -1
- package/docs/troubleshooting.md +40 -0
- package/docs/web-ui.md +29 -6
- package/package.json +2 -1
- package/templates/.claude/commands/explore-case.md +11 -6
- package/templates/.claude/commands/gen-spec.md +10 -5
- package/templates/.claude/commands/heal-spec.md +11 -7
- 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-B7HeglDd.js → dist-2msWI-5M.js} +1 -1
- package/ui/dist/assets/{dist-DfBpjgZT.js → dist-4U_gPHXf.js} +1 -1
- package/ui/dist/assets/{dist-B9_GXR2G.js → dist-BMfJatHo.js} +1 -1
- package/ui/dist/assets/{dist-Bb36rNUw.js → dist-BVvHVJ3N.js} +1 -1
- package/ui/dist/assets/{dist-WvvCZeeu.js → dist-BmWaNjqO.js} +1 -1
- package/ui/dist/assets/{dist-G9KNeh6u.js → dist-BwNz_xW2.js} +1 -1
- package/ui/dist/assets/{dist-GvRUDL-2.js → dist-J36xayOv.js} +1 -1
- package/ui/dist/assets/{dist-Dqc_tfzC.js → dist-e-KhTWHJ.js} +1 -1
- package/ui/dist/assets/{dist-DevASegc.js → dist-sfZKBSMm.js} +1 -1
- package/ui/dist/assets/{dist-Dq4Vnw5N.js → dist-wjB7g6OJ.js} +1 -1
- package/ui/dist/assets/{index-BJ5D2mFK.js → index-CGbRJT3b.js} +134 -132
- package/ui/dist/assets/index-CI0i0LWc.css +2 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BXY-eKBn.css +0 -2
package/dist/scripts/doctor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
|
|
4
5
|
import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
|
|
5
6
|
import { spawnTarget } from '../lib/command.js';
|
|
@@ -25,17 +26,20 @@ function read(file) {
|
|
|
25
26
|
function nonNegotiableRules() {
|
|
26
27
|
const ids = new Set();
|
|
27
28
|
for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
|
|
28
|
-
const match = /^- \*\*(
|
|
29
|
+
const match = /^- \*\*([A-Z]\d+)\b/.exec(line.trim());
|
|
29
30
|
if (match && line.includes('[non-negotiable]'))
|
|
30
31
|
ids.add(match[1]);
|
|
31
32
|
}
|
|
32
33
|
return ids;
|
|
33
34
|
}
|
|
34
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Every rule id the framework baseline defines (`F1`..`F10`, `B1`..`B4`, `A1`..`A6`, `R1`..`R4`), so a project cannot
|
|
37
|
+
* name one that does not exist.
|
|
38
|
+
*/
|
|
35
39
|
function frameworkRuleIds() {
|
|
36
40
|
const ids = new Set();
|
|
37
41
|
for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
|
|
38
|
-
const match = /^- \*\*(
|
|
42
|
+
const match = /^- \*\*([A-Z]\d+)\b/.exec(line.trim());
|
|
39
43
|
if (match)
|
|
40
44
|
ids.add(match[1]);
|
|
41
45
|
}
|
|
@@ -70,6 +74,38 @@ function staleDistFiles() {
|
|
|
70
74
|
walk('scripts');
|
|
71
75
|
return stale;
|
|
72
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* The browser tools the workspace's `playwright-test` MCP can actually run, and the Playwright version it comes from.
|
|
79
|
+
*
|
|
80
|
+
* `playwright run-test-mcp-server` advertises every browser tool Playwright has, but a call runs in a paused test whose
|
|
81
|
+
* backend installs only the `core` and `testing` capabilities and skips the `skillOnly` tools; anything else answers
|
|
82
|
+
* `Tool "<name>" not found`. The skills' `references/tools.md` documents the substitutes, so this tells a person when
|
|
83
|
+
* an upgrade moved a tool out of that set before an exploration discovers it. `null` when the install cannot be
|
|
84
|
+
* inspected (then the check says nothing rather than guessing).
|
|
85
|
+
*/
|
|
86
|
+
function runnableBrowserTools() {
|
|
87
|
+
try {
|
|
88
|
+
const requireFrom = createRequire(path.join(ROOT, 'package.json'));
|
|
89
|
+
const testManifest = requireFrom.resolve('@playwright/test/package.json');
|
|
90
|
+
const playwrightManifest = requireFrom.resolve('playwright/package.json', { paths: [path.dirname(testManifest)] });
|
|
91
|
+
const coreManifest = requireFrom.resolve('playwright-core/package.json', { paths: [path.dirname(playwrightManifest)] });
|
|
92
|
+
const bundle = requireFrom(path.join(path.dirname(coreManifest), 'lib', 'coreBundle.js'));
|
|
93
|
+
const tools = bundle.tools?.filteredTools?.({ capabilities: ['testing'] });
|
|
94
|
+
if (!tools)
|
|
95
|
+
return null;
|
|
96
|
+
const version = JSON.parse(read(playwrightManifest) ?? '{}').version ?? 'unknown';
|
|
97
|
+
return { version, tools: tools.map((tool) => tool.schema.name) };
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Every tool the skills' `tools.md` lists under `## Runnable`, i.e. the ones an exploration is told to use. */
|
|
104
|
+
function documentedBrowserTools() {
|
|
105
|
+
const text = read(path.join(SKILLS_DIR, 'exploring-cases', 'references', 'tools.md')) ?? '';
|
|
106
|
+
const section = /^## Runnable\n([\s\S]*?)^## /m.exec(text)?.[1] ?? '';
|
|
107
|
+
return [...new Set(section.match(/browser_[a-z0-9_]+/g) ?? [])];
|
|
108
|
+
}
|
|
73
109
|
export async function main(args) {
|
|
74
110
|
if (args.includes('--help')) {
|
|
75
111
|
console.log('Usage: pnpm asrun doctor');
|
|
@@ -326,6 +362,17 @@ export async function main(args) {
|
|
|
326
362
|
warnings.push(`${file} starts the MCP server with "${command}", which does not exist: run "pnpm asrun init --force ${file}".`);
|
|
327
363
|
}
|
|
328
364
|
}
|
|
365
|
+
// The browser tools the `playwright-test` MCP can really run: the skills' `tools.md` must not name one it cannot.
|
|
366
|
+
const runnable = runnableBrowserTools();
|
|
367
|
+
if (runnable !== null) {
|
|
368
|
+
facts.push(`playwright-test mcp: ${runnable.tools.length} runnable browser tools (Playwright ${runnable.version})`);
|
|
369
|
+
const missing = documentedBrowserTools().filter((tool) => !runnable.tools.includes(tool));
|
|
370
|
+
if (missing.length > 0) {
|
|
371
|
+
warnings.push(`Playwright ${runnable.version} cannot run ${missing.join(', ')} in this MCP, but ` +
|
|
372
|
+
'.claude/skills/exploring-cases/references/tools.md names it as runnable: the test-runner backend changed ' +
|
|
373
|
+
'its capabilities. Update that file (and the substitute for each tool) before exploring.');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
329
376
|
const settings = read(path.join(ROOT, '.claude', 'settings.json'));
|
|
330
377
|
if (settings === null) {
|
|
331
378
|
warnings.push('.claude/settings.json is missing: run "pnpm asrun init" (Claude Code needs its sandbox exclusions for pnpm asrun).');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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,8 +229,8 @@ 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.');
|
|
214
234
|
}
|
|
215
235
|
// The spec is generated for every profile the case names (and runs for every profile), so a selection could only
|
|
216
236
|
// narrow it wrongly.
|
|
@@ -246,18 +266,26 @@ export function buildJobSpec(request) {
|
|
|
246
266
|
};
|
|
247
267
|
const session = { id: newSessionId(request.tool), resume: false };
|
|
248
268
|
const { file, args } = headlessInvocation(request.tool, request.command, request.caseId, options, session);
|
|
249
|
-
return
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
};
|
|
254
277
|
}
|
|
255
278
|
if (request.action === 'continue') {
|
|
256
279
|
const earlier = jobs.get(request.jobId);
|
|
257
280
|
const ai = earlier?.spec.ai;
|
|
258
281
|
if (!earlier || !ai)
|
|
259
282
|
throw new ActionError('Unknown job, or it was not an AI run on a case.');
|
|
260
|
-
|
|
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) {
|
|
261
289
|
throw new ActionError('Only a run that finished with RESULT: needs-input can be continued.');
|
|
262
290
|
}
|
|
263
291
|
if (findContinuation(jobs.list(), earlier.info.id)) {
|
|
@@ -272,12 +300,16 @@ export function buildJobSpec(request) {
|
|
|
272
300
|
fs.writeFileSync(path.join(ROOT, replyPath), request.reply.trim() + '\n');
|
|
273
301
|
// The next turn keeps how the first one was told to run (model and reasoning), so the log stays the same.
|
|
274
302
|
const { file, args } = continueInvocation(ai.tool, ai.command, replyPath, ai.sessionId, ai.model, ai.thinking);
|
|
275
|
-
return
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
+
};
|
|
281
313
|
}
|
|
282
314
|
if (request.action === 'make-cases') {
|
|
283
315
|
if (request.memo.trim() === '')
|
|
@@ -295,6 +327,7 @@ export function buildJobSpec(request) {
|
|
|
295
327
|
locks: ['ai:make-cases'],
|
|
296
328
|
acceptsInput: false,
|
|
297
329
|
stream: request.tool,
|
|
330
|
+
rerun: replayRequest(request),
|
|
298
331
|
};
|
|
299
332
|
}
|
|
300
333
|
if (!knownEnvs.includes(request.env))
|
|
@@ -364,6 +397,7 @@ export function buildJobSpec(request) {
|
|
|
364
397
|
reportId,
|
|
365
398
|
locks: ['run', ...allLocks()],
|
|
366
399
|
acceptsInput: false,
|
|
400
|
+
rerun: replayRequest(request),
|
|
367
401
|
...(updateBaselines.length > 0 && { baselineCases: updateBaselines }),
|
|
368
402
|
onFinish: () => void verifyProfiles({ env: request.env }),
|
|
369
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
|
+
}
|