@jevitate/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/dist/ai-cli.d.ts +3 -1
  2. package/dist/ai-cli.js +60 -16
  3. package/dist/ai-cli.js.map +1 -1
  4. package/dist/approval-provenance.d.ts +120 -0
  5. package/dist/approval-provenance.js +300 -0
  6. package/dist/approval-provenance.js.map +1 -0
  7. package/dist/author-journey-cli.js +4 -2
  8. package/dist/author-journey-cli.js.map +1 -1
  9. package/dist/bin.js +29019 -22450
  10. package/dist/build-info.generated.js +2 -2
  11. package/dist/builtin-analyzers.d.ts +7 -0
  12. package/dist/builtin-analyzers.js +19 -0
  13. package/dist/builtin-analyzers.js.map +1 -0
  14. package/dist/campaign-cli.d.ts +6 -0
  15. package/dist/campaign-cli.js +9 -4
  16. package/dist/campaign-cli.js.map +1 -1
  17. package/dist/catalog-analysis.d.ts +70 -0
  18. package/dist/catalog-analysis.js +507 -0
  19. package/dist/catalog-analysis.js.map +1 -0
  20. package/dist/catalog-api.d.ts +73 -0
  21. package/dist/catalog-api.js +97 -0
  22. package/dist/catalog-api.js.map +1 -0
  23. package/dist/catalog-cli.d.ts +12 -0
  24. package/dist/catalog-cli.js +224 -0
  25. package/dist/catalog-cli.js.map +1 -0
  26. package/dist/catalog-review.d.ts +29 -0
  27. package/dist/catalog-review.js +290 -0
  28. package/dist/catalog-review.js.map +1 -0
  29. package/dist/catalog.d.ts +133 -0
  30. package/dist/catalog.js +352 -0
  31. package/dist/catalog.js.map +1 -0
  32. package/dist/check-api.js +24 -2
  33. package/dist/check-api.js.map +1 -1
  34. package/dist/check-cli.d.ts +3 -0
  35. package/dist/check-cli.js +18 -4
  36. package/dist/check-cli.js.map +1 -1
  37. package/dist/check-execute.js +25 -9
  38. package/dist/check-execute.js.map +1 -1
  39. package/dist/check-plan.js +4 -1
  40. package/dist/check-plan.js.map +1 -1
  41. package/dist/check-suite.js +6 -3
  42. package/dist/check-suite.js.map +1 -1
  43. package/dist/check-types.d.ts +12 -1
  44. package/dist/check-types.js.map +1 -1
  45. package/dist/cli-output.js +82 -4
  46. package/dist/cli-output.js.map +1 -1
  47. package/dist/cli-shared.d.ts +22 -2
  48. package/dist/cli-shared.js +34 -5
  49. package/dist/cli-shared.js.map +1 -1
  50. package/dist/demo-aspect-api.d.ts +15 -0
  51. package/dist/demo-aspect-api.js +26 -1
  52. package/dist/demo-aspect-api.js.map +1 -1
  53. package/dist/demo-aspect-cli.js +64 -5
  54. package/dist/demo-aspect-cli.js.map +1 -1
  55. package/dist/exit-codes.js +4 -0
  56. package/dist/exit-codes.js.map +1 -1
  57. package/dist/explore-adversarial.d.ts +3 -1
  58. package/dist/explore-adversarial.js +2 -2
  59. package/dist/explore-adversarial.js.map +1 -1
  60. package/dist/explore-author.js +5 -2
  61. package/dist/explore-author.js.map +1 -1
  62. package/dist/explore-cli.js +144 -9
  63. package/dist/explore-cli.js.map +1 -1
  64. package/dist/explore-coverage.d.ts +6 -2
  65. package/dist/explore-coverage.js +4 -3
  66. package/dist/explore-coverage.js.map +1 -1
  67. package/dist/explore-feature.d.ts +3 -1
  68. package/dist/explore-feature.js +2 -2
  69. package/dist/explore-feature.js.map +1 -1
  70. package/dist/explore-goal.d.ts +37 -6
  71. package/dist/explore-goal.js +61 -48
  72. package/dist/explore-goal.js.map +1 -1
  73. package/dist/explore-shared.d.ts +12 -7
  74. package/dist/explore-shared.js +9 -1
  75. package/dist/explore-shared.js.map +1 -1
  76. package/dist/goal-run-flags.d.ts +2 -0
  77. package/dist/goal-run-flags.js +8 -1
  78. package/dist/goal-run-flags.js.map +1 -1
  79. package/dist/http-5xx-outcome.d.ts +8 -3
  80. package/dist/http-5xx-outcome.js +5 -15
  81. package/dist/http-5xx-outcome.js.map +1 -1
  82. package/dist/init-cli.d.ts +7 -0
  83. package/dist/init-cli.js +79 -9
  84. package/dist/init-cli.js.map +1 -1
  85. package/dist/init-codeowners.d.ts +39 -0
  86. package/dist/init-codeowners.js +91 -0
  87. package/dist/init-codeowners.js.map +1 -0
  88. package/dist/init-keys.d.ts +3 -1
  89. package/dist/init-keys.js +20 -8
  90. package/dist/init-keys.js.map +1 -1
  91. package/dist/init-skills.d.ts +85 -7
  92. package/dist/init-skills.js +303 -36
  93. package/dist/init-skills.js.map +1 -1
  94. package/dist/jev-advisor.d.ts +72 -0
  95. package/dist/jev-advisor.js +172 -0
  96. package/dist/jev-advisor.js.map +1 -0
  97. package/dist/journey-api.d.ts +34 -2
  98. package/dist/journey-api.js +51 -20
  99. package/dist/journey-api.js.map +1 -1
  100. package/dist/journey-cli.js +156 -12
  101. package/dist/journey-cli.js.map +1 -1
  102. package/dist/journey-review-api.d.ts +19 -0
  103. package/dist/journey-review-api.js +46 -0
  104. package/dist/journey-review-api.js.map +1 -0
  105. package/dist/journey-review-store.d.ts +16 -0
  106. package/dist/journey-review-store.js +95 -0
  107. package/dist/journey-review-store.js.map +1 -0
  108. package/dist/journey-review.d.ts +47 -0
  109. package/dist/journey-review.js +499 -0
  110. package/dist/journey-review.js.map +1 -0
  111. package/dist/journey-verify.js +14 -1
  112. package/dist/journey-verify.js.map +1 -1
  113. package/dist/key-report.d.ts +20 -5
  114. package/dist/key-report.js +32 -9
  115. package/dist/key-report.js.map +1 -1
  116. package/dist/kill-signal.js +10 -2
  117. package/dist/kill-signal.js.map +1 -1
  118. package/dist/load-cli.js +4 -2
  119. package/dist/load-cli.js.map +1 -1
  120. package/dist/log-classes.d.ts +73 -0
  121. package/dist/log-classes.js +167 -0
  122. package/dist/log-classes.js.map +1 -0
  123. package/dist/log-correlation.d.ts +29 -10
  124. package/dist/log-correlation.js +44 -21
  125. package/dist/log-correlation.js.map +1 -1
  126. package/dist/login-cli.d.ts +15 -0
  127. package/dist/login-cli.js +116 -0
  128. package/dist/login-cli.js.map +1 -0
  129. package/dist/logs-cli.js +3 -1
  130. package/dist/logs-cli.js.map +1 -1
  131. package/dist/mcp-api.d.ts +1 -0
  132. package/dist/mcp-api.js +61 -12
  133. package/dist/mcp-api.js.map +1 -1
  134. package/dist/mcp-cli-runner.d.ts +2 -0
  135. package/dist/mcp-cli-runner.js +3 -1
  136. package/dist/mcp-cli-runner.js.map +1 -1
  137. package/dist/mcp-cli-tools.d.ts +5 -0
  138. package/dist/mcp-cli-tools.js +158 -23
  139. package/dist/mcp-cli-tools.js.map +1 -1
  140. package/dist/mission-cli.js +8 -4
  141. package/dist/mission-cli.js.map +1 -1
  142. package/dist/mission-journal.d.ts +4 -1
  143. package/dist/mission-journal.js +9 -3
  144. package/dist/mission-journal.js.map +1 -1
  145. package/dist/mission-queue-cli.js +21 -2
  146. package/dist/mission-queue-cli.js.map +1 -1
  147. package/dist/mission-queue-runner.d.ts +6 -0
  148. package/dist/mission-queue-runner.js +33 -3
  149. package/dist/mission-queue-runner.js.map +1 -1
  150. package/dist/multi-run-cli.d.ts +7 -1
  151. package/dist/multi-run-cli.js +10 -3
  152. package/dist/multi-run-cli.js.map +1 -1
  153. package/dist/multi-run.d.ts +38 -3
  154. package/dist/multi-run.js +115 -23
  155. package/dist/multi-run.js.map +1 -1
  156. package/dist/persona-login.d.ts +199 -0
  157. package/dist/persona-login.js +574 -0
  158. package/dist/persona-login.js.map +1 -0
  159. package/dist/pre-approval.d.ts +98 -0
  160. package/dist/pre-approval.js +175 -0
  161. package/dist/pre-approval.js.map +1 -0
  162. package/dist/program.js +10 -0
  163. package/dist/program.js.map +1 -1
  164. package/dist/project-dir.d.ts +2 -1
  165. package/dist/project-dir.js +3 -1
  166. package/dist/project-dir.js.map +1 -1
  167. package/dist/readiness.d.ts +29 -0
  168. package/dist/readiness.js +291 -0
  169. package/dist/readiness.js.map +1 -0
  170. package/dist/regression-cli.js +4 -2
  171. package/dist/regression-cli.js.map +1 -1
  172. package/dist/report-api.d.ts +24 -2
  173. package/dist/report-api.js +33 -5
  174. package/dist/report-api.js.map +1 -1
  175. package/dist/report-cli.js +8 -1
  176. package/dist/report-cli.js.map +1 -1
  177. package/dist/result-schema.d.ts +12 -0
  178. package/dist/result-schema.js +8 -0
  179. package/dist/result-schema.js.map +1 -1
  180. package/dist/run-index.d.ts +4 -2
  181. package/dist/run-index.js +3 -1
  182. package/dist/run-index.js.map +1 -1
  183. package/dist/run-key-preflight.d.ts +2 -1
  184. package/dist/run-key-preflight.js +3 -2
  185. package/dist/run-key-preflight.js.map +1 -1
  186. package/dist/run-metadata.d.ts +26 -0
  187. package/dist/run-metadata.js +41 -0
  188. package/dist/run-metadata.js.map +1 -0
  189. package/dist/run-tags-cli.d.ts +12 -0
  190. package/dist/run-tags-cli.js +35 -0
  191. package/dist/run-tags-cli.js.map +1 -0
  192. package/dist/site-cli.d.ts +1 -1
  193. package/dist/site-cli.js +24 -1
  194. package/dist/site-cli.js.map +1 -1
  195. package/dist/source-cli.js +4 -2
  196. package/dist/source-cli.js.map +1 -1
  197. package/dist/suite-explore-options.d.ts +14 -0
  198. package/dist/suite-explore-options.js +7 -0
  199. package/dist/suite-explore-options.js.map +1 -1
  200. package/dist/sweep-api.d.ts +266 -0
  201. package/dist/sweep-api.js +828 -0
  202. package/dist/sweep-api.js.map +1 -0
  203. package/dist/sweep-cli.d.ts +9 -0
  204. package/dist/sweep-cli.js +115 -0
  205. package/dist/sweep-cli.js.map +1 -0
  206. package/dist/target-config.d.ts +5 -2
  207. package/dist/target-config.js +15 -0
  208. package/dist/target-config.js.map +1 -1
  209. package/dist/ux-cli.js +3 -1
  210. package/dist/ux-cli.js.map +1 -1
  211. package/dist/ux-usability.d.ts +9 -3
  212. package/dist/ux-usability.js +5 -3
  213. package/dist/ux-usability.js.map +1 -1
  214. package/dist/verify-fix-cli.js +4 -2
  215. package/dist/verify-fix-cli.js.map +1 -1
  216. package/package.json +12 -12
  217. package/skills/jevitate-demo/SKILL.md +5 -2
  218. package/skills/jevitate-getting-started/SKILL.md +12 -0
  219. package/skills/jevitate-run-journey/SKILL.md +17 -4
  220. package/skills/jevitate-test-campaign/SKILL.md +26 -5
@@ -0,0 +1,828 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, extname, join, resolve } from "node:path";
3
+ import { MISSION_EXIT_CODES, RunTagError, clock, combineOutcomes, validateRunTags, } from "@jevitate/domain";
4
+ import { currentEngineInfo } from "./engine.js";
5
+ import { artifactStamp } from "./mission-journal.js";
6
+ import { logsDirFor } from "./project-dir.js";
7
+ import { summarizeRun } from "./multi-run.js";
8
+ import { CLI_TOOL_SPECS, buildCliArgv } from "./mcp-cli-tools.js";
9
+ import { confineMcpPath, defaultMcpPathRoots } from "./mcp-paths.js";
10
+ import { EXPLORE_STRATEGIES } from "./cli-shared.js";
11
+ import { validateAllowControlPatterns, validateDenyPatterns } from "@jevitate/explore";
12
+ import { parseAuthCheck } from "./persona-login.js";
13
+ /**
14
+ * `jevitate sweep` (#425): one release check over many targets (features/routes) × personas, run
15
+ * with bounded concurrency, resumable, with ONE aggregated `sweep.result.json`.
16
+ *
17
+ * Every target is the SAME `explore` command a person would type (the multi-run/campaign pattern:
18
+ * re-parsed in a fresh program, so every run goes through exactly the validation, gateways,
19
+ * invariants and checks a single run does, and through the shared browser pool and the machine-wide
20
+ * admission of resource governance). The sweep only adds:
21
+ *
22
+ * - a targets file (`.tsv` or `.json`) validated up front — every problem listed, nothing run;
23
+ * - a worker pool of `--concurrency` runs;
24
+ * - `--resume`: a target whose `<out>/<id>/run.envelope.json` holds a finished run is not re-run;
25
+ * - `--stop-on-env-failure K`: when the first K runs of this invocation ALL failed for an
26
+ * environment/setup reason (auth expired, target unreachable, a crash, a command-level error),
27
+ * nothing else starts — the environment is broken, not the app;
28
+ * - the aggregate: per-target outcome, depth, tags; defects deduped by fingerprint ACROSS targets
29
+ * (one finding, N sightings); environment causes grouped.
30
+ *
31
+ * Every run is tagged `target=<id>` plus the sweep's and the target's own tags (#426).
32
+ * Storage-state CONTENTS are never read here — only their (confined) paths travel.
33
+ */
34
+ export class SweepSpecError extends Error {
35
+ code = "E_SWEEP_SPEC";
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = "SweepSpecError";
39
+ }
40
+ }
41
+ export class SweepArgsError extends Error {
42
+ code = "E_SWEEP_ARGS";
43
+ constructor(message) {
44
+ super(message);
45
+ this.name = "SweepArgsError";
46
+ }
47
+ }
48
+ /** Upper bound on `--concurrency`: each run is a whole browser mission. */
49
+ export const MAX_SWEEP_CONCURRENCY = 16;
50
+ /** Upper bound on targets in one file. */
51
+ export const MAX_SWEEP_TARGETS = 1000;
52
+ const TARGET_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
53
+ /**
54
+ * The `explore` options a target may set (`options` in JSON, extra columns in TSV): exactly the
55
+ * value-typed arguments MCP `run_exploration` takes — so a targets file never grants a run more than
56
+ * an MCP call could (no paths, no log commands, no secrets, no hooks) — minus the ones the sweep
57
+ * owns per target (url, strategy, goal, persona/session, tags, out, AI mode, multi-run, journey anchor).
58
+ */
59
+ const SWEEP_OWNED = new Set([
60
+ "url",
61
+ "fromJourney",
62
+ "atStep",
63
+ "params",
64
+ "env",
65
+ "baseUrl",
66
+ "strategy",
67
+ "goal",
68
+ "storageState",
69
+ "saveStorageState",
70
+ "persona",
71
+ "personas",
72
+ "actor",
73
+ "repeat",
74
+ "minAgreement",
75
+ "out",
76
+ "real",
77
+ "fakeAi",
78
+ "tags",
79
+ "headed",
80
+ "slowMo",
81
+ // One Jev provider per sweep (`sweep --jev-provider`), like the AI mode.
82
+ "jevProvider",
83
+ ]);
84
+ const VALUE_KINDS = new Set(["string", "integer", "number", "boolean", "string[]", "viewport"]);
85
+ function exploreParams() {
86
+ const spec = CLI_TOOL_SPECS.find((t) => t.name === "run_exploration");
87
+ if (spec?.command === undefined)
88
+ throw new Error("run_exploration spec missing");
89
+ return spec.command.params;
90
+ }
91
+ /** The target option name → its typed explore argument (the allowed set). */
92
+ export function sweepTargetOptions() {
93
+ const out = {};
94
+ for (const [name, p] of Object.entries(exploreParams())) {
95
+ if (SWEEP_OWNED.has(name) || p.positional === true || !VALUE_KINDS.has(p.kind))
96
+ continue;
97
+ out[name] = p;
98
+ }
99
+ return out;
100
+ }
101
+ function isRecord(v) {
102
+ return v !== null && typeof v === "object" && !Array.isArray(v);
103
+ }
104
+ /** `a=1;b=2` (a TSV `tags` cell) → `{a: "1", b: "2"}`. */
105
+ function tsvTags(cell, where, problems) {
106
+ const out = {};
107
+ for (const part of cell.split(";").map((p) => p.trim()).filter((p) => p !== "")) {
108
+ const eq = part.indexOf("=");
109
+ if (eq <= 0) {
110
+ problems.push(`${where}: tags entry ${JSON.stringify(part)} must be key=value (entries separated by ';')`);
111
+ continue;
112
+ }
113
+ out[part.slice(0, eq)] = part.slice(eq + 1);
114
+ }
115
+ return out;
116
+ }
117
+ /** A TSV option cell typed by its parameter's kind. */
118
+ function tsvOption(p, cell, key, where, problems) {
119
+ switch (p.kind) {
120
+ case "integer":
121
+ case "number": {
122
+ const n = Number(cell);
123
+ if (cell.trim() === "" || !Number.isFinite(n))
124
+ problems.push(`${where}: ${key} must be a number, got ${JSON.stringify(cell)}`);
125
+ return n;
126
+ }
127
+ case "boolean":
128
+ if (cell !== "true" && cell !== "false")
129
+ problems.push(`${where}: ${key} must be true or false, got ${JSON.stringify(cell)}`);
130
+ return cell === "true";
131
+ case "string[]":
132
+ if (cell.trim().startsWith("[")) {
133
+ try {
134
+ return JSON.parse(cell);
135
+ }
136
+ catch {
137
+ problems.push(`${where}: ${key} must be a JSON array of strings or a single value`);
138
+ return [];
139
+ }
140
+ }
141
+ return [cell];
142
+ case "viewport": {
143
+ const m = /^(\d+)x(\d+)$/.exec(cell.trim());
144
+ if (m === null)
145
+ problems.push(`${where}: ${key} must be <width>x<height>, got ${JSON.stringify(cell)}`);
146
+ return m === null ? undefined : { width: Number(m[1]), height: Number(m[2]) };
147
+ }
148
+ default:
149
+ return cell;
150
+ }
151
+ }
152
+ const TSV_FIELDS = new Set(["id", "url", "route", "persona", "strategy", "goal", "tags"]);
153
+ function parseTsv(text, path) {
154
+ const problems = [];
155
+ const lines = text.split(/\r?\n/).map((l, i) => ({ l, n: i + 1 })).filter(({ l }) => l.trim() !== "" && !l.trimStart().startsWith("#"));
156
+ if (lines.length === 0)
157
+ return { rows: [], problems: [`${path}: no header row (id, url|route, persona, strategy, goal, tags, <explore options>…)`] };
158
+ const header = lines[0].l.split("\t").map((h) => h.trim());
159
+ const seen = new Set();
160
+ for (const h of header) {
161
+ if (h === "")
162
+ problems.push(`${path}: the header has an empty column name`);
163
+ if (seen.has(h))
164
+ problems.push(`${path}: the header names column ${JSON.stringify(h)} twice`);
165
+ seen.add(h);
166
+ }
167
+ if (!seen.has("id"))
168
+ problems.push(`${path}: the header has no 'id' column`);
169
+ const options = sweepTargetOptions();
170
+ const rows = [];
171
+ for (const { l, n } of lines.slice(1)) {
172
+ const where = `${path}:${n}`;
173
+ const cells = l.split("\t");
174
+ if (cells.length > header.length)
175
+ problems.push(`${where}: ${cells.length} cells but the header has ${header.length} columns`);
176
+ const fields = {};
177
+ const opts = {};
178
+ header.forEach((h, i) => {
179
+ const cell = (cells[i] ?? "").trim();
180
+ if (cell === "")
181
+ return;
182
+ if (h === "tags")
183
+ fields.tags = tsvTags(cell, where, problems);
184
+ else if (TSV_FIELDS.has(h))
185
+ fields[h] = cell;
186
+ else if (options[h] !== undefined)
187
+ opts[h] = tsvOption(options[h], cell, h, where, problems);
188
+ else
189
+ opts[h] = cell; // refused (unknown option) with the allowed list, below
190
+ });
191
+ if (Object.keys(opts).length > 0)
192
+ fields.options = opts;
193
+ rows.push({ where, fields, fromTsv: true });
194
+ }
195
+ return { rows, problems };
196
+ }
197
+ function parseJson(text, path) {
198
+ let raw;
199
+ try {
200
+ raw = JSON.parse(text);
201
+ }
202
+ catch (err) {
203
+ return { rows: [], problems: [`${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`] };
204
+ }
205
+ const problems = [];
206
+ const list = Array.isArray(raw) ? raw : isRecord(raw) && Array.isArray(raw.targets) ? raw.targets : null;
207
+ if (list === null)
208
+ return { rows: [], problems: [`${path} must be an array of targets or {"targets": [...]}`] };
209
+ let baseUrl;
210
+ let defaults;
211
+ if (isRecord(raw)) {
212
+ for (const k of Object.keys(raw))
213
+ if (!["targets", "baseUrl", "defaults", "name"].includes(k))
214
+ problems.push(`${path}: unknown key ${JSON.stringify(k)} (allowed: targets, baseUrl, defaults, name)`);
215
+ if (raw.baseUrl !== undefined) {
216
+ if (typeof raw.baseUrl === "string")
217
+ baseUrl = raw.baseUrl;
218
+ else
219
+ problems.push(`${path}: baseUrl must be a string`);
220
+ }
221
+ if (raw.defaults !== undefined) {
222
+ if (isRecord(raw.defaults))
223
+ defaults = raw.defaults;
224
+ else
225
+ problems.push(`${path}: defaults must be an object`);
226
+ }
227
+ }
228
+ const rows = list.map((t, i) => {
229
+ const where = `${path}: targets[${i}]${isRecord(t) && typeof t.id === "string" ? ` (${t.id})` : ""}`;
230
+ if (!isRecord(t)) {
231
+ problems.push(`${where} must be an object`);
232
+ return { where, fields: {}, fromTsv: false };
233
+ }
234
+ return { where, fields: t, fromTsv: false };
235
+ });
236
+ return { ...(baseUrl === undefined ? {} : { baseUrl }), ...(defaults === undefined ? {} : { defaults }), rows, problems };
237
+ }
238
+ const TARGET_KEYS = new Set(["id", "url", "route", "persona", "strategy", "goal", "tags", "options"]);
239
+ const DEFAULT_KEYS = new Set(["persona", "strategy", "goal", "tags", "options"]);
240
+ function httpUrl(v) {
241
+ try {
242
+ const u = new URL(v);
243
+ return u.protocol === "http:" || u.protocol === "https:" ? u : undefined;
244
+ }
245
+ catch {
246
+ return undefined;
247
+ }
248
+ }
249
+ function personaOf(value, base, roots, where, problems) {
250
+ let name;
251
+ let file;
252
+ if (typeof value === "string") {
253
+ const eq = value.indexOf("=");
254
+ // `name=path`, or a bare path (the persona is named after the file).
255
+ if (eq > 0 && !value.slice(0, eq).includes("/")) {
256
+ name = value.slice(0, eq);
257
+ file = value.slice(eq + 1);
258
+ }
259
+ else
260
+ file = value;
261
+ }
262
+ else if (isRecord(value) && typeof value.storageState === "string") {
263
+ file = value.storageState;
264
+ if (value.name !== undefined) {
265
+ if (typeof value.name === "string")
266
+ name = value.name;
267
+ else
268
+ problems.push(`${where}: persona.name must be a string`);
269
+ }
270
+ }
271
+ else {
272
+ problems.push(`${where}: persona must be a storage-state path, "name=<path>" or {"name", "storageState"}`);
273
+ return undefined;
274
+ }
275
+ if (file === undefined || file.trim() === "") {
276
+ problems.push(`${where}: persona storage state path is empty`);
277
+ return undefined;
278
+ }
279
+ const abs = resolve(base, file);
280
+ const personaName = name ?? basename(abs, extname(abs));
281
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(personaName)) {
282
+ problems.push(`${where}: persona name ${JSON.stringify(personaName)} must be 1-64 of [A-Za-z0-9_.-], starting alphanumeric`);
283
+ return undefined;
284
+ }
285
+ try {
286
+ // The same confinement as an MCP session argument (the targets file may come from an MCP call):
287
+ // inside the targets file's directory, the project or ~/.jevitate — never a repo's .jevitate/.
288
+ const confined = confineMcpPath(abs, `persona of ${where}`, roots, { session: true });
289
+ if (!existsSync(confined)) {
290
+ problems.push(`${where}: persona storage state not found: ${confined}`);
291
+ return undefined;
292
+ }
293
+ return { name: personaName, storageState: confined };
294
+ }
295
+ catch (err) {
296
+ problems.push(err instanceof Error ? err.message : String(err));
297
+ return undefined;
298
+ }
299
+ }
300
+ const OPTION_SPEC = (params) => ({
301
+ name: "sweep-target",
302
+ description: "",
303
+ command: { path: "explore", params, omitted: {} },
304
+ });
305
+ /** A target's options as explore flags (typed and closed: an unknown option or a wrong type is a problem). */
306
+ function optionArgvOf(options, where, problems) {
307
+ const allowed = sweepTargetOptions();
308
+ const unknown = Object.keys(options).filter((k) => allowed[k] === undefined);
309
+ if (unknown.length > 0) {
310
+ problems.push(`${where}: unknown or not-allowed explore option(s) ${unknown.join(", ")} (a target may set: ${Object.keys(allowed).sort().join(", ")}; ` +
311
+ "url/route, persona, strategy, goal and tags are their own fields)");
312
+ return [];
313
+ }
314
+ try {
315
+ const argv = buildCliArgv(OPTION_SPEC(allowed), options, []);
316
+ // ["explore", …flags, "--"]: the flags only.
317
+ return argv.slice(1, argv.lastIndexOf("--"));
318
+ }
319
+ catch (err) {
320
+ problems.push(`${where}: ${err instanceof Error ? err.message : String(err)}`);
321
+ return [];
322
+ }
323
+ }
324
+ const stringList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : typeof v === "string" ? [v] : []);
325
+ /**
326
+ * The checks `explore` itself makes on these options before a browser opens — made here, for every
327
+ * target, before ANY run: regexes (`deny`, `allowControl`) compile (and an allow-control pattern is
328
+ * never a blanket waiver), `authCheck` is a known mode, and the minimum effort is a goal run's.
329
+ */
330
+ function validateRunShaping(options, strategy, where, problems) {
331
+ for (const [key, flag, check] of [
332
+ ["deny", "deny", validateDenyPatterns],
333
+ ["allowControl", "allowControl", validateAllowControlPatterns],
334
+ ]) {
335
+ if (options[key] === undefined)
336
+ continue;
337
+ try {
338
+ check(stringList(options[key]), flag);
339
+ }
340
+ catch (err) {
341
+ problems.push(`${where}: ${err instanceof Error ? err.message : String(err)}`);
342
+ }
343
+ }
344
+ if (options.authCheck !== undefined) {
345
+ try {
346
+ parseAuthCheck(typeof options.authCheck === "string" ? options.authCheck : String(options.authCheck));
347
+ }
348
+ catch (err) {
349
+ problems.push(`${where}: ${(err instanceof Error ? err.message : String(err)).replace("--auth-check", "authCheck")}`);
350
+ }
351
+ }
352
+ for (const key of ["minActions", "minDistinctStates"]) {
353
+ const v = options[key];
354
+ if (v === undefined)
355
+ continue;
356
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1)
357
+ problems.push(`${where}: ${key} must be a positive integer`);
358
+ if (strategy !== "goal" || options.feature !== undefined)
359
+ problems.push(`${where}: ${key} is supported only with strategy goal (a goal or find-out run), not ${options.feature !== undefined ? "a feature run" : strategy}`);
360
+ }
361
+ }
362
+ /**
363
+ * Loads and validates a targets file (`.tsv`: a header row then one target per line; `.json`: an
364
+ * array or `{baseUrl?, defaults?, targets}`). EVERY problem is listed in one `SweepSpecError`
365
+ * (exit 64) — nothing runs on a half-valid file.
366
+ */
367
+ export function loadSweepTargets(path, opts = {}) {
368
+ const abs = resolve(path);
369
+ if (!existsSync(abs))
370
+ throw new SweepSpecError(`targets file not found: ${abs}`);
371
+ const text = readFileSync(abs, "utf8");
372
+ const isTsv = /\.(tsv|txt)$/i.test(abs);
373
+ const parsed = isTsv ? parseTsv(text, path) : parseJson(text, path);
374
+ const problems = [...parsed.problems];
375
+ const defaults = parsed.defaults ?? {};
376
+ for (const k of Object.keys(defaults))
377
+ if (!DEFAULT_KEYS.has(k))
378
+ problems.push(`${path}: defaults.${k} is not a default a target can inherit (allowed: ${[...DEFAULT_KEYS].join(", ")})`);
379
+ const env = opts.env ?? process.env;
380
+ const baseUrl = opts.baseUrl ?? parsed.baseUrl ?? env["JEVITATE_BASE_URL"];
381
+ if (baseUrl !== undefined && httpUrl(baseUrl) === undefined)
382
+ problems.push(`base URL ${JSON.stringify(baseUrl)} must be an absolute http(s) URL`);
383
+ const base = dirname(abs);
384
+ const roots = [base, ...(opts.roots ?? defaultMcpPathRoots())];
385
+ if (parsed.rows.length === 0 && problems.length === 0)
386
+ problems.push(`${path}: no targets`);
387
+ if (parsed.rows.length > MAX_SWEEP_TARGETS)
388
+ problems.push(`${path}: ${parsed.rows.length} targets; at most ${MAX_SWEEP_TARGETS} per sweep`);
389
+ let defaultTags = {};
390
+ if (defaults.tags !== undefined) {
391
+ try {
392
+ defaultTags = validateRunTags(defaults.tags, "defaults.tags");
393
+ }
394
+ catch (err) {
395
+ problems.push(`${path}: ${err instanceof Error ? err.message : String(err)}`);
396
+ }
397
+ }
398
+ if (defaults.options !== undefined && !isRecord(defaults.options))
399
+ problems.push(`${path}: defaults.options must be an object`);
400
+ const ids = new Set();
401
+ const targets = [];
402
+ for (const row of parsed.rows) {
403
+ const { where } = row;
404
+ const f = row.fields;
405
+ if (!row.fromTsv)
406
+ for (const k of Object.keys(f))
407
+ if (!TARGET_KEYS.has(k))
408
+ problems.push(`${where}: unknown key ${JSON.stringify(k)} (allowed: ${[...TARGET_KEYS].join(", ")})`);
409
+ const id = f.id;
410
+ if (typeof id !== "string" || !TARGET_ID.test(id)) {
411
+ problems.push(`${where}: id must be 1-64 of [A-Za-z0-9_.-], starting alphanumeric (it names the target's directory and its target=<id> tag)`);
412
+ continue;
413
+ }
414
+ if (ids.has(id))
415
+ problems.push(`${where}: id ${id} is used twice`);
416
+ ids.add(id);
417
+ // url | route
418
+ let url;
419
+ const route = typeof f.route === "string" ? f.route : undefined;
420
+ if (f.url !== undefined && f.route !== undefined)
421
+ problems.push(`${where}: give url or route, not both`);
422
+ else if (typeof f.url === "string") {
423
+ if (httpUrl(f.url) === undefined)
424
+ problems.push(`${where}: url ${JSON.stringify(f.url)} must be an absolute http(s) URL`);
425
+ else
426
+ url = f.url;
427
+ }
428
+ else if (route !== undefined) {
429
+ if (!route.startsWith("/"))
430
+ problems.push(`${where}: route ${JSON.stringify(route)} must start with '/'`);
431
+ else if (baseUrl === undefined)
432
+ problems.push(`${where}: route ${route} needs a base URL (--base-url, --env, the file's baseUrl or JEVITATE_BASE_URL)`);
433
+ else if (httpUrl(baseUrl) !== undefined)
434
+ url = new URL(route, baseUrl).toString();
435
+ }
436
+ else
437
+ problems.push(`${where}: needs url or route`);
438
+ const pick = (k) => (f[k] !== undefined ? f[k] : defaults[k]);
439
+ const strategy = pick("strategy");
440
+ if (strategy !== undefined && (typeof strategy !== "string" || !EXPLORE_STRATEGIES.includes(strategy))) {
441
+ problems.push(`${where}: strategy ${JSON.stringify(strategy)} must be one of ${EXPLORE_STRATEGIES.join(", ")}`);
442
+ }
443
+ const goal = pick("goal");
444
+ if (goal !== undefined && (typeof goal !== "string" || goal.trim() === ""))
445
+ problems.push(`${where}: goal must be a non-empty string`);
446
+ const options = { ...(isRecord(defaults.options) ? defaults.options : {}), ...(isRecord(f.options) ? f.options : {}) };
447
+ if (f.options !== undefined && !isRecord(f.options))
448
+ problems.push(`${where}: options must be an object`);
449
+ const effectiveStrategy = typeof strategy === "string" ? strategy : "goal";
450
+ if ((effectiveStrategy === "goal" || effectiveStrategy === "usability") && typeof goal !== "string" && options.feature === undefined) {
451
+ problems.push(`${where}: strategy ${effectiveStrategy} needs a goal${effectiveStrategy === "goal" ? " (or options.feature for a feature run)" : ""}`);
452
+ }
453
+ const optionArgv = optionArgvOf(options, where, problems);
454
+ validateRunShaping(options, effectiveStrategy, where, problems);
455
+ let tags = { ...defaultTags };
456
+ if (f.tags !== undefined) {
457
+ try {
458
+ tags = { ...tags, ...validateRunTags(f.tags, "tags") };
459
+ }
460
+ catch (err) {
461
+ if (!(err instanceof RunTagError))
462
+ throw err;
463
+ problems.push(`${where}: ${err.message}`);
464
+ }
465
+ }
466
+ if (tags.target !== undefined && tags.target !== id)
467
+ problems.push(`${where}: tag 'target' is reserved (every run is tagged target=<id>)`);
468
+ const personaRaw = pick("persona");
469
+ const persona = personaRaw === undefined ? undefined : personaOf(personaRaw, base, roots, where, problems);
470
+ if (url === undefined)
471
+ continue;
472
+ targets.push({
473
+ id,
474
+ url,
475
+ ...(route === undefined ? {} : { route }),
476
+ ...(persona === undefined ? {} : { persona }),
477
+ ...(typeof strategy === "string" ? { strategy } : {}),
478
+ ...(typeof goal === "string" ? { goal } : {}),
479
+ tags,
480
+ options,
481
+ optionArgv,
482
+ });
483
+ }
484
+ if (problems.length > 0)
485
+ throw new SweepSpecError(`invalid targets file (${problems.length} problem(s)):\n- ${problems.join("\n- ")}`);
486
+ return targets;
487
+ }
488
+ /** Failure kinds that mean the environment (or setup), not the app, ended the run. */
489
+ const ENVIRONMENT_FAILURE_KINDS = new Set([
490
+ "auth-expired",
491
+ "target-unreachable",
492
+ "target-unresponsive",
493
+ "configuration",
494
+ "degraded-environment",
495
+ "resource-limit",
496
+ "browser-disconnected",
497
+ ]);
498
+ /** Why a run failed for an environment/setup reason, if it did. */
499
+ export function environmentFailureOf(s) {
500
+ if (!s.ok)
501
+ return { kind: "setup", message: s.reason ?? `${s.error?.code ?? "E_UNKNOWN"}: ${s.error?.message ?? ""}` };
502
+ if (s.failureKind !== undefined && ENVIRONMENT_FAILURE_KINDS.has(s.failureKind))
503
+ return { kind: s.failureKind, message: s.reason ?? s.failureKind };
504
+ if (s.sessionLost !== undefined)
505
+ return { kind: "session-lost", message: s.sessionLost };
506
+ if (s.missionOutcome === "crashed")
507
+ return { kind: "crashed", message: s.reason ?? "the run crashed" };
508
+ return undefined;
509
+ }
510
+ function str(v) {
511
+ return typeof v === "string" ? v : undefined;
512
+ }
513
+ /** The finished target's row, from its envelope (pure, but for summarizeRun's transcript read). */
514
+ export function targetResultOf(target, envelope, status, envelopePath, tags) {
515
+ const strategy = target.strategy ?? (target.options.feature !== undefined ? "feature" : "goal");
516
+ const s = summarizeRun(strategy, 1, envelope, envelopePath);
517
+ const data = envelope.ok && isRecord(envelope.data) ? envelope.data : {};
518
+ const failureRec = isRecord(data.failure) ? data.failure : isRecord(data.crash) && isRecord(data.crash.failure) ? data.crash.failure : undefined;
519
+ const failure = failureRec !== undefined && typeof failureRec.kind === "string"
520
+ ? { kind: failureRec.kind, message: str(failureRec.message) ?? "" }
521
+ : !envelope.ok
522
+ ? { kind: "setup", message: `${envelope.error.code}: ${envelope.error.message}` }
523
+ : undefined;
524
+ const env = environmentFailureOf(s);
525
+ const defects = (Array.isArray(data.defects) ? data.defects.length : 0) + (Array.isArray(data.hangs) ? data.hangs.length : 0);
526
+ return {
527
+ id: target.id,
528
+ url: target.url,
529
+ ...(target.persona === undefined ? {} : { persona: target.persona.name }),
530
+ strategy,
531
+ tags,
532
+ status: envelope.ok ? status : "error",
533
+ missionOutcome: envelope.ok ? s.missionOutcome : "inconclusive",
534
+ ...(s.goalOutcome === undefined ? {} : { goalOutcome: s.goalOutcome }),
535
+ exitCode: envelope.ok ? s.exitCode : MISSION_EXIT_CODES.inconclusive,
536
+ ...(data.defectOutcome === undefined ? {} : { defectOutcome: data.defectOutcome }),
537
+ ...(data.depth === undefined ? {} : { depth: data.depth }),
538
+ ...(Array.isArray(data.safetyOverrides) && data.safetyOverrides.length > 0 ? { safetyOverrides: data.safetyOverrides } : {}),
539
+ ...(failure === undefined ? {} : { failure }),
540
+ ...(env === undefined ? {} : { environmentFailure: env }),
541
+ defects,
542
+ ...(s.resultPath === undefined ? {} : { resultPath: s.resultPath }),
543
+ envelopePath,
544
+ };
545
+ }
546
+ /** Defects (and hangs) of every finished target, deduped by fingerprint across targets. */
547
+ export function dedupeSweepDefects(runs) {
548
+ const byFp = new Map();
549
+ for (const { target, envelope, resultPath } of runs) {
550
+ if (!envelope.ok || !isRecord(envelope.data))
551
+ continue;
552
+ const list = [...(Array.isArray(envelope.data.defects) ? envelope.data.defects : []), ...(Array.isArray(envelope.data.hangs) ? envelope.data.hangs : [])];
553
+ const seenHere = new Set();
554
+ for (const d of list) {
555
+ if (!isRecord(d) || typeof d.fingerprint !== "string" || seenHere.has(d.fingerprint))
556
+ continue;
557
+ seenHere.add(d.fingerprint);
558
+ const cur = byFp.get(d.fingerprint) ?? { first: d, sightings: [], advisory: true };
559
+ const count = typeof d.count === "number" ? d.count : typeof d.occurrences === "number" ? d.occurrences : undefined;
560
+ cur.sightings.push({
561
+ target,
562
+ ...(str(d.route) === undefined ? {} : { route: str(d.route) }),
563
+ ...(str(d.url) === undefined ? {} : { url: str(d.url) }),
564
+ ...(count === undefined ? {} : { count }),
565
+ ...(resultPath === undefined ? {} : { resultPath }),
566
+ });
567
+ // Advisory only when EVERY sighting was advisory (a gating sighting anywhere makes it a defect).
568
+ cur.advisory = cur.advisory && d.advisory === true;
569
+ byFp.set(d.fingerprint, cur);
570
+ }
571
+ }
572
+ const out = [...byFp.entries()].map(([fingerprint, { first, sightings, advisory }]) => ({
573
+ fingerprint,
574
+ kind: str(first.kind) ?? "unknown",
575
+ ...(str(first.title) === undefined ? {} : { title: str(first.title) }),
576
+ ...(advisory ? { advisory: true } : {}),
577
+ ...(str(first.level) === undefined ? {} : { level: str(first.level) }),
578
+ ...(str(first.source) === undefined ? {} : { source: str(first.source) }),
579
+ ...(str(first.message) === undefined ? {} : { message: str(first.message) }),
580
+ sightingCount: sightings.length,
581
+ targets: [...new Set(sightings.map((s) => s.target))],
582
+ sightings,
583
+ }));
584
+ out.sort((a, b) => b.sightingCount - a.sightingCount || a.fingerprint.localeCompare(b.fingerprint));
585
+ return out;
586
+ }
587
+ /**
588
+ * Every run's environment causes grouped across targets: its classified backend-log faults
589
+ * (`environmentFaults: {causes: [{ruleId, source, message, count}]}`, #422) and its starved-host
590
+ * findings (`environmentDegraded: [{finding, cause, detail}]`, #203).
591
+ */
592
+ export function groupEnvironmentCauses(runs) {
593
+ const groups = new Map();
594
+ const add = (target, ruleId, source, message, count) => {
595
+ const key = `${ruleId ?? ""}\u0000${source ?? ""}\u0000${message}`;
596
+ const g = groups.get(key) ?? { ...(ruleId === undefined ? {} : { ruleId }), ...(source === undefined ? {} : { source }), message, count: 0, targets: new Set() };
597
+ g.count += count;
598
+ g.targets.add(target);
599
+ groups.set(key, g);
600
+ };
601
+ for (const { target, envelope } of runs) {
602
+ if (!envelope.ok || !isRecord(envelope.data))
603
+ continue;
604
+ // #422: classified backend-log environment faults (`environmentFaults.causes`).
605
+ const ef = envelope.data.environmentFaults;
606
+ if (isRecord(ef) && Array.isArray(ef.causes)) {
607
+ for (const c of ef.causes) {
608
+ if (!isRecord(c))
609
+ continue;
610
+ add(target, str(c.ruleId), str(c.source), str(c.message) ?? str(c.ruleId) ?? "environment fault", typeof c.count === "number" ? c.count : 1);
611
+ }
612
+ }
613
+ // #203: findings met on a starved host (`environmentDegraded`, a per-finding array).
614
+ const ed = envelope.data.environmentDegraded;
615
+ if (Array.isArray(ed)) {
616
+ for (const c of ed) {
617
+ if (!isRecord(c))
618
+ continue;
619
+ add(target, str(c.finding), undefined, str(c.cause) ?? str(c.detail) ?? "environment degraded", 1);
620
+ }
621
+ }
622
+ }
623
+ return [...groups.values()]
624
+ .map((g) => ({ ...(g.ruleId === undefined ? {} : { ruleId: g.ruleId }), ...(g.source === undefined ? {} : { source: g.source }), message: g.message, count: g.count, targets: [...g.targets] }))
625
+ .sort((a, b) => b.targets.length - a.targets.length || b.count - a.count || a.message.localeCompare(b.message));
626
+ }
627
+ function groupFailures(rows) {
628
+ const groups = new Map();
629
+ for (const r of rows) {
630
+ if (r.environmentFailure === undefined)
631
+ continue;
632
+ const g = groups.get(r.environmentFailure.kind) ?? { message: r.environmentFailure.message, count: 0, targets: [] };
633
+ g.count += 1;
634
+ g.targets.push(r.id);
635
+ groups.set(r.environmentFailure.kind, g);
636
+ }
637
+ return [...groups.entries()].map(([kind, g]) => ({ kind, message: g.message, count: g.count, targets: g.targets })).sort((a, b) => b.count - a.count);
638
+ }
639
+ /** The run's tags: the sweep's, then the target's own (which win), then `target=<id>`. */
640
+ export function runTagsFor(plan, target) {
641
+ return { ...plan.tags, ...target.tags, target: target.id };
642
+ }
643
+ /** The `explore` argv one target runs with. */
644
+ export function targetArgv(plan, target, runDir) {
645
+ return [
646
+ "explore",
647
+ "--url",
648
+ target.url,
649
+ ...(target.strategy === undefined ? [] : ["--strategy", target.strategy]),
650
+ ...(target.goal === undefined ? [] : ["--goal", target.goal]),
651
+ ...(target.persona === undefined ? [] : ["--storage-state", target.persona.storageState]),
652
+ ...Object.entries(runTagsFor(plan, target)).flatMap(([k, v]) => ["--tag", `${k}=${v}`]),
653
+ ...plan.runArgs,
654
+ ...target.optionArgv,
655
+ "--out",
656
+ runDir,
657
+ "--json",
658
+ ];
659
+ }
660
+ /** A previous invocation's finished run for this target (`--resume`), else undefined. */
661
+ export function finishedEnvelope(envelopePath) {
662
+ if (!existsSync(envelopePath))
663
+ return undefined;
664
+ try {
665
+ const env = JSON.parse(readFileSync(envelopePath, "utf8"));
666
+ if (!isRecord(env) || env.ok !== true || !isRecord(env.data))
667
+ return undefined; // an error is retried
668
+ if (env.data.interrupted !== undefined)
669
+ return undefined;
670
+ return env;
671
+ }
672
+ catch {
673
+ return undefined; // a torn file is not a finished run
674
+ }
675
+ }
676
+ function writeJson(path, value) {
677
+ mkdirSync(dirname(path), { recursive: true });
678
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
679
+ }
680
+ /** Folds the target rows into the aggregate (pure). */
681
+ export function aggregateSweep(args) {
682
+ const { plan } = args;
683
+ const rows = plan.targets.map((t, i) => args.rows[i] ??
684
+ {
685
+ id: t.id,
686
+ url: t.url,
687
+ ...(t.persona === undefined ? {} : { persona: t.persona.name }),
688
+ strategy: t.strategy ?? (t.options.feature !== undefined ? "feature" : "goal"),
689
+ tags: runTagsFor(plan, t),
690
+ status: args.complete || args.aborted !== undefined ? "skipped" : "pending",
691
+ missionOutcome: "inconclusive",
692
+ exitCode: MISSION_EXIT_CODES.inconclusive,
693
+ defects: 0,
694
+ ...(args.aborted === undefined ? {} : { failure: { kind: "sweep-stopped", message: args.aborted.reason } }),
695
+ });
696
+ const done = rows.filter((r, i) => args.rows[i] !== undefined);
697
+ const byOutcome = {};
698
+ for (const r of done)
699
+ byOutcome[r.missionOutcome] = (byOutcome[r.missionOutcome] ?? 0) + 1;
700
+ // Deterministic whatever order runs finished in: sightings and causes follow the targets file.
701
+ const order = new Map(plan.targets.map((t, i) => [t.id, i]));
702
+ const finished = [...args.finished].sort((a, b) => (order.get(a.target) ?? 0) - (order.get(b.target) ?? 0));
703
+ const defects = dedupeSweepDefects(finished);
704
+ const failures = groupFailures(done);
705
+ const pending = plan.targets.length - done.length;
706
+ const finalOutcome = combineOutcomes(rows.map((r) => r.missionOutcome));
707
+ const missionOutcome = args.complete ? finalOutcome : "inconclusive";
708
+ const reason = args.aborted !== undefined
709
+ ? args.aborted.reason
710
+ : !args.complete
711
+ ? `incomplete: ${done.length} of ${plan.targets.length} target(s) finished`
712
+ : undefined;
713
+ return {
714
+ kind: "sweep",
715
+ targetsPath: plan.targetsPath,
716
+ outDir: plan.outDir,
717
+ resultPath: join(plan.outDir, "sweep.result.json"),
718
+ startedAt: args.startedAt,
719
+ ...(args.finishedAt === undefined ? {} : { finishedAt: args.finishedAt }),
720
+ complete: args.complete && args.aborted === undefined,
721
+ concurrency: plan.concurrency,
722
+ tags: plan.tags,
723
+ missionOutcome: args.aborted === undefined ? missionOutcome : "inconclusive",
724
+ exitCode: MISSION_EXIT_CODES[args.aborted === undefined ? missionOutcome : "inconclusive"],
725
+ ...(reason === undefined ? {} : { reason }),
726
+ ...(args.aborted === undefined ? {} : { aborted: args.aborted }),
727
+ engine: args.engine ?? currentEngineInfo(),
728
+ summary: {
729
+ targets: plan.targets.length,
730
+ ran: done.filter((r) => r.status === "ran").length,
731
+ resumed: done.filter((r) => r.status === "resumed").length,
732
+ errors: done.filter((r) => r.status === "error").length,
733
+ skipped: args.complete || args.aborted !== undefined ? pending : 0,
734
+ pending: args.complete || args.aborted !== undefined ? 0 : pending,
735
+ byOutcome,
736
+ defects: defects.filter((d) => d.advisory !== true).length,
737
+ environmentFailures: done.filter((r) => r.environmentFailure !== undefined).length,
738
+ },
739
+ targets: rows,
740
+ defects,
741
+ environment: { causes: groupEnvironmentCauses(finished), failures },
742
+ };
743
+ }
744
+ /** The default sweep directory: `.jevitate/logs/<date>/sweep-<stamp>`. */
745
+ export function defaultSweepOutDir(iso = clock.nowIso()) {
746
+ return join(logsDirFor(iso), `sweep-${artifactStamp(iso)}`);
747
+ }
748
+ /**
749
+ * Runs the plan: resumed targets first (read back, never re-run), then a pool of `concurrency`
750
+ * workers over the rest in file order. `sweep.result.json` is rewritten after every finished run.
751
+ */
752
+ export async function runSweep(opts) {
753
+ const { plan } = opts;
754
+ const now = opts.nowIso ?? (() => clock.nowIso());
755
+ const startedAt = now();
756
+ mkdirSync(plan.outDir, { recursive: true });
757
+ const resultPath = join(plan.outDir, "sweep.result.json");
758
+ const rows = plan.targets.map(() => undefined);
759
+ const finished = [];
760
+ const queue = [];
761
+ plan.targets.forEach((t, i) => {
762
+ const envelopePath = join(plan.outDir, t.id, "run.envelope.json");
763
+ const prior = plan.resume ? finishedEnvelope(envelopePath) : undefined;
764
+ if (prior === undefined) {
765
+ queue.push(i);
766
+ return;
767
+ }
768
+ const row = targetResultOf(t, prior, "resumed", envelopePath, runTagsFor(plan, t));
769
+ rows[i] = row;
770
+ finished.push({ target: t.id, envelope: prior, ...(row.resultPath === undefined ? {} : { resultPath: row.resultPath }) });
771
+ });
772
+ let aborted;
773
+ const thisRun = [];
774
+ const snapshot = (complete, finishedAt) => aggregateSweep({ plan, rows, finished, startedAt, complete, ...(finishedAt === undefined ? {} : { finishedAt }), ...(aborted === undefined ? {} : { aborted }) });
775
+ writeJson(resultPath, snapshot(queue.length === 0));
776
+ const disarm = opts.armKill?.((signal, exitCode) => {
777
+ const partial = snapshot(false);
778
+ const result = { ...partial, reason: `interrupted by ${signal}; ${partial.targets.length - partial.summary.pending} of ${partial.summary.targets} target(s) finished`, interrupted: { signal }, exitCode };
779
+ writeJson(resultPath, result);
780
+ return result;
781
+ });
782
+ const runOne = async (i) => {
783
+ const target = plan.targets[i];
784
+ const runDir = join(plan.outDir, target.id);
785
+ mkdirSync(runDir, { recursive: true });
786
+ const envelopePath = join(runDir, "run.envelope.json");
787
+ let envelope;
788
+ try {
789
+ envelope = await opts.runOnce({ target, argv: targetArgv(plan, target, runDir), runDir });
790
+ }
791
+ catch (err) {
792
+ envelope = { ok: false, error: { code: "E_SWEEP_RUN", message: err instanceof Error ? err.message : String(err) } };
793
+ }
794
+ writeJson(envelopePath, envelope);
795
+ const row = targetResultOf(target, envelope, "ran", envelopePath, runTagsFor(plan, target));
796
+ rows[i] = row;
797
+ if (envelope.ok)
798
+ finished.push({ target: target.id, envelope, ...(row.resultPath === undefined ? {} : { resultPath: row.resultPath }) });
799
+ thisRun.push(row);
800
+ // --stop-on-env-failure K: judged once, on the K-th run of this invocation.
801
+ const k = plan.stopOnEnvFailure;
802
+ if (k !== undefined && aborted === undefined && thisRun.length === k && thisRun.every((r) => r.environmentFailure !== undefined)) {
803
+ const kinds = [...new Set(thisRun.map((r) => r.environmentFailure.kind))].join(", ");
804
+ aborted = {
805
+ reason: `stopped: the first ${k} run(s) all failed for environment/setup reasons (${kinds}) — fix the environment, then re-run with --resume`,
806
+ afterRuns: k,
807
+ };
808
+ }
809
+ writeJson(resultPath, snapshot(false));
810
+ };
811
+ try {
812
+ let next = 0;
813
+ const worker = async () => {
814
+ while (aborted === undefined && next < queue.length) {
815
+ const i = queue[next++];
816
+ await runOne(i);
817
+ }
818
+ };
819
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(plan.concurrency, queue.length)) }, () => worker()));
820
+ }
821
+ finally {
822
+ disarm?.();
823
+ }
824
+ const result = snapshot(aborted === undefined, now());
825
+ writeJson(resultPath, result);
826
+ return result;
827
+ }
828
+ //# sourceMappingURL=sweep-api.js.map