@asrunjs/asrun 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +84 -63
- 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/notes.md +87 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +58 -6
- package/.claude/skills/exploring-cases/references/readiness.md +7 -5
- package/.claude/skills/exploring-cases/references/tools.md +151 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +47 -18
- package/.claude/skills/generating-specs/SKILL.md +99 -54
- 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 +61 -20
- package/README.md +10 -8
- package/bin/asrun.mjs +2 -0
- package/dist/lib/case-doc.d.ts +41 -4
- package/dist/lib/case-doc.js +109 -17
- package/dist/lib/explore-notes.d.ts +74 -0
- package/dist/lib/explore-notes.js +242 -0
- package/dist/lib/paths.d.ts +2 -0
- package/dist/lib/paths.js +2 -0
- package/dist/scripts/check-cases.js +89 -1
- package/dist/scripts/cli.js +9 -2
- package/dist/scripts/doctor.js +50 -3
- package/dist/scripts/explore-notes.d.ts +1 -0
- package/dist/scripts/explore-notes.js +131 -0
- 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 +40 -5
- package/docs/agents/framework-dev.md +4 -0
- package/docs/ai-workflow.md +56 -16
- package/docs/cases.md +32 -8
- package/docs/commands.md +17 -1
- package/docs/theme-guide.html +56 -0
- package/docs/troubleshooting.md +40 -0
- package/docs/web-ui.md +59 -15
- package/package.json +3 -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 +2 -0
- package/ui/dist/assets/{dist-WvvCZeeu.js → dist--BXJInTq.js} +1 -1
- package/ui/dist/assets/{dist-B7HeglDd.js → dist-BMmNFIUH.js} +1 -1
- package/ui/dist/assets/{dist-DevASegc.js → dist-BXNaYHeh.js} +1 -1
- package/ui/dist/assets/{dist-Bb36rNUw.js → dist-ByLbEOlF.js} +1 -1
- package/ui/dist/assets/{dist-Dqc_tfzC.js → dist-C9ra6_BD.js} +1 -1
- package/ui/dist/assets/{dist-DfBpjgZT.js → dist-CK8mow8u.js} +1 -1
- package/ui/dist/assets/{dist-G9KNeh6u.js → dist-CQS0So-i.js} +1 -1
- package/ui/dist/assets/{dist-GvRUDL-2.js → dist-CTrwRc3Q.js} +1 -1
- package/ui/dist/assets/{dist-B9_GXR2G.js → dist-DTYj2vUD.js} +1 -1
- package/ui/dist/assets/{dist-Dq4Vnw5N.js → dist-mV0_nQZe.js} +1 -1
- package/ui/dist/assets/index-BzMqkmQ9.js +348 -0
- package/ui/dist/assets/index-DcJR-8Mt.css +2 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BJ5D2mFK.js +0 -346
- package/ui/dist/assets/index-BXY-eKBn.css +0 -2
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { CASES_DIR } from '../lib/paths.js';
|
|
3
3
|
import { listCaseFileIds, readCaseMeta } from '../lib/cases.js';
|
|
4
|
-
import { parseCaseDocument } from '../lib/case-doc.js';
|
|
4
|
+
import { parseCaseDocument, splitLines } from '../lib/case-doc.js';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { ROOT } from '../lib/paths.js';
|
|
7
7
|
import { checksNeedingTarget, parseTargets, readCaseText, REQUIRED_FIELDS, targetRefsIn } from '../lib/targets.js';
|
|
8
8
|
import { parseCaseValues } from '../lib/values.js';
|
|
9
|
+
const LIST_ITEM = /^[ \t]*(?:[-*+]|\d+\.)[ \t]+\S/;
|
|
10
|
+
const HEADING = /^##[ \t]+\S/;
|
|
11
|
+
/** The words the panel and `case:check` treat as a marker: one claim carries one signal. */
|
|
12
|
+
const MARKER_WORD = /\b(?:to be confirmed(?:\s+by\s+QA)?|TBC|TBD|not observed)\b/i;
|
|
13
|
+
const indentOf = (text) => text.length - text.trimStart().length;
|
|
14
|
+
/**
|
|
15
|
+
* The line a question immediately hangs under: the nearest line above it that is indented less and can carry text.
|
|
16
|
+
* `null` when that line is a paragraph (the model extends a paragraph upwards on purpose) or nothing qualifies.
|
|
17
|
+
*/
|
|
18
|
+
function immediateAnchor(lines, questionLine, indent) {
|
|
19
|
+
const at = lines.findIndex((line) => line.number === questionLine);
|
|
20
|
+
for (let index = at - 1; index >= 0; index--) {
|
|
21
|
+
const text = lines[index].text;
|
|
22
|
+
if (text.trim() === '' || HEADING.test(text) || /^[ \t]*```/.test(text))
|
|
23
|
+
return null;
|
|
24
|
+
if (indentOf(text) >= indent)
|
|
25
|
+
continue;
|
|
26
|
+
// Another question at a shallower depth is not the sentence this one stands for.
|
|
27
|
+
if (/^[ \t]*[-*+][ \t]+(?:\u{1F6AB}|\u{2753}|\u270D)/u.test(text))
|
|
28
|
+
continue;
|
|
29
|
+
return LIST_ITEM.test(text) ? lines[index].number : null;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
9
33
|
/** Step numbers of the lines marked `[input]` or `[writes]` in "## Steps" (a person does them), and the numbers that have a `- Recipe:` line under them (or an old "## Inputs" line). */
|
|
10
34
|
function inputSteps(id) {
|
|
11
35
|
const doc = parseCaseDocument(fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8'));
|
|
@@ -41,6 +65,11 @@ function questionProblems(id) {
|
|
|
41
65
|
if (step && question.indent < 4) {
|
|
42
66
|
warnings.push(`the question on line ${question.line} is indented by ${question.indent} space(s) under step ${step.number}; 4 renders it inside the step`);
|
|
43
67
|
}
|
|
68
|
+
const anchor = immediateAnchor(splitLines(doc.text), question.line, question.indent);
|
|
69
|
+
if (anchor !== null && anchor !== question.parent.line) {
|
|
70
|
+
const above = splitLines(doc.text).find((line) => line.number === anchor)?.text.trim() ?? '';
|
|
71
|
+
warnings.push(`the question on line ${question.line} hangs under "${above.slice(0, 80)}", but the panel shows "${question.parent.text.slice(0, 80)}" as the text above it; indent it under that sentence or lift that sentence to a top-level bullet`);
|
|
72
|
+
}
|
|
44
73
|
}
|
|
45
74
|
for (const step of doc.steps) {
|
|
46
75
|
if (step.recipes.length === 0)
|
|
@@ -62,6 +91,58 @@ function questionProblems(id) {
|
|
|
62
91
|
}
|
|
63
92
|
return warnings;
|
|
64
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Problems with the markers (`🔍 to be confirmed by QA`, `👁️ not observed`): one claim carries one signal, and a
|
|
96
|
+
* marker says what a person has to decide. A marker inside a question block is a failure; the old form without a
|
|
97
|
+
* "why" is a warning, so old cases keep working.
|
|
98
|
+
*/
|
|
99
|
+
function markerProblems(id) {
|
|
100
|
+
const text = fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8');
|
|
101
|
+
const doc = parseCaseDocument(text);
|
|
102
|
+
const fail = [];
|
|
103
|
+
const warn = [];
|
|
104
|
+
for (const question of doc.questions) {
|
|
105
|
+
const end = question.hasAnswerLine ? question.answerRange.start : question.blockRange.end;
|
|
106
|
+
if (MARKER_WORD.test(text.slice(question.blockRange.start, end))) {
|
|
107
|
+
fail.push(`the question on line ${question.line} holds a marker word ("to be confirmed" / "not observed"): one claim carries one signal, so keep the question and drop the marker`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (doc.values?.hasBlock && MARKER_WORD.test(text.slice(doc.values.range.start, doc.values.range.end))) {
|
|
111
|
+
fail.push('a marker inside the "## Values" yaml block would become part of the expected value: put it on the prose line above the block');
|
|
112
|
+
}
|
|
113
|
+
const parentLines = new Set(doc.questions.map((question) => question.parent?.line).filter((line) => line !== undefined));
|
|
114
|
+
for (const marker of doc.markers) {
|
|
115
|
+
if (parentLines.has(marker.line)) {
|
|
116
|
+
fail.push(`line ${marker.line} carries a marker and a question under it: keep the question (drop the marker) or the marker (drop the question)`);
|
|
117
|
+
}
|
|
118
|
+
if (marker.detail === null) {
|
|
119
|
+
warn.push(`the marker on line ${marker.line} does not say what a person must confirm; write "🔍 to be confirmed: <the one thing to decide>"` +
|
|
120
|
+
(marker.evidence === null
|
|
121
|
+
? ', plus "evidence: .explore/observations/<case-id>/<image>.png" when a screenshot says it better'
|
|
122
|
+
: ''));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { fail, warn };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Problems with the `## Rules` bullets: a rule may name the step it belongs to (`Step 3:`, `Before step 3:` or
|
|
129
|
+
* `After step 3:`). A reference to a step the case does not have is a failure, because the rule would silently lose its
|
|
130
|
+
* scope. An unprefixed rule applies to the whole case and is always valid.
|
|
131
|
+
*/
|
|
132
|
+
function ruleProblems(id) {
|
|
133
|
+
const doc = parseCaseDocument(fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8'));
|
|
134
|
+
const failures = [];
|
|
135
|
+
for (const rule of doc.rules) {
|
|
136
|
+
const match = /^(?:(?:before|after)[ \t]+)?step[ \t]+(\d+)\b/i.exec(rule.text);
|
|
137
|
+
if (!match)
|
|
138
|
+
continue;
|
|
139
|
+
const number = Number(match[1]);
|
|
140
|
+
if (!doc.steps.some((step) => step.number === number)) {
|
|
141
|
+
failures.push(`the rule on line ${rule.line} refers to step ${number}, which this case does not have`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return failures;
|
|
145
|
+
}
|
|
65
146
|
/**
|
|
66
147
|
* Warnings for the old layout. `## Data` and `## Inputs` are deprecated: the one-sentence summary belongs in
|
|
67
148
|
* `writes-note`, the cleanup in steps, and a person's recipe under its step. Cases keep working, so this never fails.
|
|
@@ -131,6 +212,8 @@ for (const id of ids) {
|
|
|
131
212
|
console.log(`warn ${id}: ${message}`);
|
|
132
213
|
for (const message of targets.fail)
|
|
133
214
|
throw new Error(message);
|
|
215
|
+
for (const message of ruleProblems(id))
|
|
216
|
+
throw new Error(message);
|
|
134
217
|
const { marked, recorded } = inputSteps(id);
|
|
135
218
|
const missing = marked.filter((step) => !recorded.includes(step));
|
|
136
219
|
if (missing.length > 0) {
|
|
@@ -140,6 +223,11 @@ for (const id of ids) {
|
|
|
140
223
|
console.log(`warn ${id}: ${message}`);
|
|
141
224
|
for (const message of questionProblems(id))
|
|
142
225
|
console.log(`warn ${id}: ${message}`);
|
|
226
|
+
const markers = markerProblems(id);
|
|
227
|
+
for (const message of markers.warn)
|
|
228
|
+
console.log(`warn ${id}: ${message}`);
|
|
229
|
+
for (const message of markers.fail)
|
|
230
|
+
throw new Error(message);
|
|
143
231
|
}
|
|
144
232
|
catch (error) {
|
|
145
233
|
problems++;
|
package/dist/scripts/cli.js
CHANGED
|
@@ -32,13 +32,19 @@ async function main() {
|
|
|
32
32
|
process.exitCode = await migrate(rest);
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
+
if (command === 'explore:notes') {
|
|
36
|
+
// The script reads `process.argv` itself; drop the subcommand so it sees only its own arguments.
|
|
37
|
+
process.argv.splice(2, 1);
|
|
38
|
+
await import('./explore-notes.js');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
35
41
|
if (command === 'help' || command === '--help-init') {
|
|
36
42
|
console.log(USAGE);
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
45
|
await import('./run.js');
|
|
40
46
|
}
|
|
41
|
-
const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate] [options]
|
|
47
|
+
const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate|explore:notes] [options]
|
|
42
48
|
|
|
43
49
|
(no subcommand) Run the tests of one environment. See "pnpm asrun --help".
|
|
44
50
|
init Create the files a workspace needs (never overwrites; --update refreshes generated files,
|
|
@@ -50,5 +56,6 @@ const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate] [options]
|
|
|
50
56
|
upgrade Install the newest asrun, refresh the workspace files and apply its migrations, after checking the
|
|
51
57
|
target release for breaking changes (see "pnpm asrun upgrade --help").
|
|
52
58
|
migrate Apply the workspace migrations of the installed release, or list them (see "pnpm asrun migrate
|
|
53
|
-
--help"). "upgrade" runs it; use it after a manual install
|
|
59
|
+
--help"). "upgrade" runs it; use it after a manual install.
|
|
60
|
+
explore:notes Print or validate the local exploration note of a case (see "pnpm asrun explore:notes --help").`;
|
|
54
61
|
await main();
|
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,131 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isValidCaseId } from '../lib/case-id.js';
|
|
3
|
+
import { caseSha256, exploreNotesDir, exploreNotesFile, noteFreshness, readExploreNotes, } from '../lib/explore-notes.js';
|
|
4
|
+
import { ROOT } from '../lib/paths.js';
|
|
5
|
+
import { loadProfiles } from '../lib/profiles.js';
|
|
6
|
+
const USAGE = `Usage: pnpm explore:notes <case-id> [--profile <profile-id>] [--json] [--check] [--hash]
|
|
7
|
+
|
|
8
|
+
Prints the exploration notes /explore-case wrote for a case under .explore/notes/<case-id>/.
|
|
9
|
+
They are local, git-ignored and optional: /gen-spec reads a fresh one to verify instead of rediscovering the page,
|
|
10
|
+
and falls back to a full replay when it is missing or stale.
|
|
11
|
+
|
|
12
|
+
--profile <id> only this profile's note
|
|
13
|
+
--json the parsed notes (and any errors) as JSON, for a run that reads them
|
|
14
|
+
--check validate every note; fails on a malformed file, warns on a stale one
|
|
15
|
+
--hash print the case file's sha256, for "caseSha256" in a note
|
|
16
|
+
|
|
17
|
+
Paths are relative to the workspace root (${ROOT}).`;
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
let caseId;
|
|
20
|
+
let profile;
|
|
21
|
+
let json = false;
|
|
22
|
+
let check = false;
|
|
23
|
+
let hash = false;
|
|
24
|
+
let help = false;
|
|
25
|
+
for (let index = 0; index < args.length; index++) {
|
|
26
|
+
const arg = args[index];
|
|
27
|
+
if (arg === '--help' || arg === '-h')
|
|
28
|
+
help = true;
|
|
29
|
+
else if (arg === '--json')
|
|
30
|
+
json = true;
|
|
31
|
+
else if (arg === '--check')
|
|
32
|
+
check = true;
|
|
33
|
+
else if (arg === '--hash')
|
|
34
|
+
hash = true;
|
|
35
|
+
else if (arg === '--profile') {
|
|
36
|
+
const value = args[++index];
|
|
37
|
+
if (value === undefined || value.startsWith('--')) {
|
|
38
|
+
console.error(`--profile needs a profile id.\n\n${USAGE}`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
profile = value;
|
|
42
|
+
}
|
|
43
|
+
else if (!arg.startsWith('--') && caseId === undefined)
|
|
44
|
+
caseId = arg;
|
|
45
|
+
else {
|
|
46
|
+
console.error(`Unexpected argument: ${arg}\n\n${USAGE}`);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (help) {
|
|
51
|
+
console.log(USAGE);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
if (caseId === undefined) {
|
|
55
|
+
console.error(USAGE);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
if (!isValidCaseId(caseId)) {
|
|
59
|
+
console.error(`"${caseId}" is not a valid case id (one optional group folder, for example documents/landing-page).`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
if (hash) {
|
|
63
|
+
const value = caseSha256(caseId);
|
|
64
|
+
if (value === null) {
|
|
65
|
+
console.error(`cases/${caseId}.md does not exist.`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
console.log(value);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
const { notes, errors } = readExploreNotes(caseId, profile);
|
|
72
|
+
const relative = (file) => path.relative(ROOT, file).split(path.sep).join('/');
|
|
73
|
+
if (json) {
|
|
74
|
+
console.log(JSON.stringify({
|
|
75
|
+
caseId,
|
|
76
|
+
notes: notes.map((note) => ({ ...note, freshness: noteFreshness(note) })),
|
|
77
|
+
errors: errors.map((error) => ({ file: relative(error.file), problems: error.problems })),
|
|
78
|
+
}, null, 2));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.log(`Exploration notes for cases/${caseId}.md (${relative(exploreNotesDir(caseId))})`);
|
|
82
|
+
if (notes.length === 0)
|
|
83
|
+
console.log(' none yet: /gen-spec replays the case.');
|
|
84
|
+
for (const note of notes) {
|
|
85
|
+
console.log(` ${note.profile.padEnd(24)} ${describe(note)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let failed = false;
|
|
89
|
+
for (const error of errors) {
|
|
90
|
+
failed = true;
|
|
91
|
+
for (const problem of error.problems)
|
|
92
|
+
console.log(`FAIL ${relative(error.file)}: ${problem}`);
|
|
93
|
+
}
|
|
94
|
+
if (check) {
|
|
95
|
+
const value = caseSha256(caseId);
|
|
96
|
+
if (value === null) {
|
|
97
|
+
failed = true;
|
|
98
|
+
console.log(`FAIL cases/${caseId}.md does not exist`);
|
|
99
|
+
}
|
|
100
|
+
const known = new Set(knownProfiles());
|
|
101
|
+
for (const note of notes) {
|
|
102
|
+
const freshness = noteFreshness(note);
|
|
103
|
+
if (freshness.state !== 'fresh') {
|
|
104
|
+
console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: ${freshness.reason}; current caseSha256 is ${value ?? '(unknown)'}`);
|
|
105
|
+
}
|
|
106
|
+
if (!known.has(note.profile)) {
|
|
107
|
+
console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: "${note.profile}" is not in profiles.yaml`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (failed) {
|
|
111
|
+
console.error('\nA note that fails here is ignored by /gen-spec. Fix it, or delete it and let the case be replayed.');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function describe(note) {
|
|
116
|
+
const freshness = noteFreshness(note);
|
|
117
|
+
const steps = Object.keys(note.steps)
|
|
118
|
+
.sort((a, b) => Number(a) - Number(b))
|
|
119
|
+
.join(',');
|
|
120
|
+
const targets = Object.keys(note.targets ?? {}).sort().join(',');
|
|
121
|
+
const state = freshness.state === 'fresh' ? 'fresh' : `stale (${freshness.reason})`;
|
|
122
|
+
return `${state} steps ${steps || '-'} targets ${targets || '-'} captured ${note.capturedAt}${note.updatedAt ? ` refreshed ${note.updatedAt}` : ''}`;
|
|
123
|
+
}
|
|
124
|
+
function knownProfiles() {
|
|
125
|
+
try {
|
|
126
|
+
return loadProfiles().map((entry) => entry.id);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -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
|
};
|