@jevitate/cli 0.1.0 → 0.2.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/README.md +23 -0
- package/dist/ai-cli.js +40 -30
- package/dist/ai-cli.js.map +1 -1
- package/dist/author-journey-cli.d.ts +4 -0
- package/dist/author-journey-cli.js +109 -0
- package/dist/author-journey-cli.js.map +1 -0
- package/dist/bin.js +58077 -22454
- package/dist/bin.js.map +1 -1
- package/dist/browser-run-options.d.ts +58 -0
- package/dist/browser-run-options.js +106 -0
- package/dist/browser-run-options.js.map +1 -0
- package/dist/build-info.generated.d.ts +2 -0
- package/dist/build-info.generated.js +10 -0
- package/dist/build-info.generated.js.map +1 -0
- package/dist/check-api.d.ts +5 -0
- package/dist/check-api.js +233 -0
- package/dist/check-api.js.map +1 -0
- package/dist/check-budget.d.ts +26 -0
- package/dist/check-budget.js +89 -0
- package/dist/check-budget.js.map +1 -0
- package/dist/check-cli.d.ts +31 -0
- package/dist/check-cli.js +109 -0
- package/dist/check-cli.js.map +1 -0
- package/dist/check-execute.d.ts +25 -0
- package/dist/check-execute.js +385 -0
- package/dist/check-execute.js.map +1 -0
- package/dist/check-plan.d.ts +113 -0
- package/dist/check-plan.js +448 -0
- package/dist/check-plan.js.map +1 -0
- package/dist/check-suite.d.ts +171 -0
- package/dist/check-suite.js +515 -0
- package/dist/check-suite.js.map +1 -0
- package/dist/check-types.d.ts +181 -0
- package/dist/check-types.js +35 -0
- package/dist/check-types.js.map +1 -0
- package/dist/cli-args.d.ts +26 -0
- package/dist/cli-args.js +50 -0
- package/dist/cli-args.js.map +1 -0
- package/dist/cli-output.d.ts +102 -0
- package/dist/cli-output.js +516 -0
- package/dist/cli-output.js.map +1 -0
- package/dist/cli-reference.d.ts +3 -0
- package/dist/cli-reference.js +81 -0
- package/dist/cli-reference.js.map +1 -0
- package/dist/cli-refusal.d.ts +12 -0
- package/dist/cli-refusal.js +52 -0
- package/dist/cli-refusal.js.map +1 -0
- package/dist/cli-shared.d.ts +330 -0
- package/dist/cli-shared.js +492 -0
- package/dist/cli-shared.js.map +1 -0
- package/dist/cli-surface.d.ts +33 -0
- package/dist/cli-surface.js +48 -0
- package/dist/cli-surface.js.map +1 -0
- package/dist/conversation-options.d.ts +15 -0
- package/dist/conversation-options.js +10 -0
- package/dist/conversation-options.js.map +1 -0
- package/dist/credentials-file.d.ts +15 -0
- package/dist/credentials-file.js +56 -0
- package/dist/credentials-file.js.map +1 -0
- package/dist/data-dir.d.ts +1 -1
- package/dist/data-dir.js +1 -1
- package/dist/defect-evidence.d.ts +141 -0
- package/dist/defect-evidence.js +502 -0
- package/dist/defect-evidence.js.map +1 -0
- package/dist/demo-aspect-api.d.ts +209 -0
- package/dist/demo-aspect-api.js +397 -0
- package/dist/demo-aspect-api.js.map +1 -0
- package/dist/demo-aspect-cli.d.ts +3 -0
- package/dist/demo-aspect-cli.js +241 -0
- package/dist/demo-aspect-cli.js.map +1 -0
- package/dist/demo-capture.d.ts +108 -0
- package/dist/demo-capture.js +438 -0
- package/dist/demo-capture.js.map +1 -0
- package/dist/engine.d.ts +35 -0
- package/dist/engine.js +25 -0
- package/dist/engine.js.map +1 -0
- package/dist/environments.d.ts +155 -0
- package/dist/environments.js +364 -0
- package/dist/environments.js.map +1 -0
- package/dist/exit-codes.d.ts +46 -0
- package/dist/exit-codes.js +91 -0
- package/dist/exit-codes.js.map +1 -0
- package/dist/explore-adversarial.d.ts +137 -0
- package/dist/explore-adversarial.js +218 -0
- package/dist/explore-adversarial.js.map +1 -0
- package/dist/explore-api.d.ts +7 -187
- package/dist/explore-api.js +7 -301
- package/dist/explore-api.js.map +1 -1
- package/dist/explore-author.d.ts +79 -0
- package/dist/explore-author.js +86 -0
- package/dist/explore-author.js.map +1 -0
- package/dist/explore-cli.d.ts +7 -0
- package/dist/explore-cli.js +963 -0
- package/dist/explore-cli.js.map +1 -0
- package/dist/explore-coverage.d.ts +158 -0
- package/dist/explore-coverage.js +240 -0
- package/dist/explore-coverage.js.map +1 -0
- package/dist/explore-feature.d.ts +127 -0
- package/dist/explore-feature.js +220 -0
- package/dist/explore-feature.js.map +1 -0
- package/dist/explore-goal.d.ts +253 -0
- package/dist/explore-goal.js +374 -0
- package/dist/explore-goal.js.map +1 -0
- package/dist/explore-shared.d.ts +152 -0
- package/dist/explore-shared.js +129 -0
- package/dist/explore-shared.js.map +1 -0
- package/dist/explore-specs.d.ts +53 -0
- package/dist/explore-specs.js +333 -0
- package/dist/explore-specs.js.map +1 -0
- package/dist/findings-filing.d.ts +64 -0
- package/dist/findings-filing.js +108 -0
- package/dist/findings-filing.js.map +1 -0
- package/dist/fixture-auth.d.ts +52 -0
- package/dist/fixture-auth.js +82 -0
- package/dist/fixture-auth.js.map +1 -0
- package/dist/fixture-cli.d.ts +70 -0
- package/dist/fixture-cli.js +178 -0
- package/dist/fixture-cli.js.map +1 -0
- package/dist/github-issue-filer.d.ts +62 -0
- package/dist/github-issue-filer.js +188 -0
- package/dist/github-issue-filer.js.map +1 -0
- package/dist/host-health-run.d.ts +24 -0
- package/dist/host-health-run.js +27 -0
- package/dist/host-health-run.js.map +1 -0
- package/dist/http-5xx-outcome.d.ts +6 -0
- package/dist/http-5xx-outcome.js +39 -0
- package/dist/http-5xx-outcome.js.map +1 -0
- package/dist/inbox-cli.d.ts +7 -0
- package/dist/inbox-cli.js +197 -0
- package/dist/inbox-cli.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/init-cli.d.ts +4 -0
- package/dist/init-cli.js +127 -0
- package/dist/init-cli.js.map +1 -0
- package/dist/init-keys.d.ts +19 -2
- package/dist/init-keys.js +15 -4
- package/dist/init-keys.js.map +1 -1
- package/dist/init-next-steps.d.ts +35 -0
- package/dist/init-next-steps.js +79 -0
- package/dist/init-next-steps.js.map +1 -0
- package/dist/invariants-file.d.ts +34 -0
- package/dist/invariants-file.js +68 -0
- package/dist/invariants-file.js.map +1 -0
- package/dist/invariants-validate.d.ts +30 -0
- package/dist/invariants-validate.js +105 -0
- package/dist/invariants-validate.js.map +1 -0
- package/dist/journey-annotate-api.d.ts +76 -0
- package/dist/journey-annotate-api.js +256 -0
- package/dist/journey-annotate-api.js.map +1 -0
- package/dist/journey-annotate-cli.d.ts +9 -0
- package/dist/journey-annotate-cli.js +179 -0
- package/dist/journey-annotate-cli.js.map +1 -0
- package/dist/journey-api.d.ts +79 -2
- package/dist/journey-api.js +136 -19
- package/dist/journey-api.js.map +1 -1
- package/dist/journey-cli.d.ts +4 -0
- package/dist/journey-cli.js +370 -0
- package/dist/journey-cli.js.map +1 -0
- package/dist/journey-demo-api.d.ts +112 -0
- package/dist/journey-demo-api.js +392 -0
- package/dist/journey-demo-api.js.map +1 -0
- package/dist/journey-demo-cli.d.ts +10 -0
- package/dist/journey-demo-cli.js +155 -0
- package/dist/journey-demo-cli.js.map +1 -0
- package/dist/kill-signal.d.ts +193 -0
- package/dist/kill-signal.js +284 -0
- package/dist/kill-signal.js.map +1 -0
- package/dist/launch-armed.d.ts +37 -0
- package/dist/launch-armed.js +31 -0
- package/dist/launch-armed.js.map +1 -0
- package/dist/ledger-api.d.ts +106 -0
- package/dist/ledger-api.js +237 -0
- package/dist/ledger-api.js.map +1 -0
- package/dist/ledger-cli.d.ts +18 -0
- package/dist/ledger-cli.js +107 -0
- package/dist/ledger-cli.js.map +1 -0
- package/dist/load-api.d.ts +29 -11
- package/dist/load-api.js +23 -15
- package/dist/load-api.js.map +1 -1
- package/dist/load-cli.d.ts +4 -0
- package/dist/load-cli.js +97 -0
- package/dist/load-cli.js.map +1 -0
- package/dist/log-correlation.d.ts +147 -0
- package/dist/log-correlation.js +325 -0
- package/dist/log-correlation.js.map +1 -0
- package/dist/log-lines.d.ts +131 -0
- package/dist/log-lines.js +389 -0
- package/dist/log-lines.js.map +1 -0
- package/dist/log-sources.d.ts +71 -0
- package/dist/log-sources.js +235 -0
- package/dist/log-sources.js.map +1 -0
- package/dist/logs-cli.d.ts +3 -0
- package/dist/logs-cli.js +51 -0
- package/dist/logs-cli.js.map +1 -0
- package/dist/logs-retention.d.ts +28 -0
- package/dist/logs-retention.js +84 -0
- package/dist/logs-retention.js.map +1 -0
- package/dist/mcp-api.d.ts +89 -1
- package/dist/mcp-api.js +472 -18
- package/dist/mcp-api.js.map +1 -1
- package/dist/mcp-args.d.ts +38 -0
- package/dist/mcp-args.js +131 -0
- package/dist/mcp-args.js.map +1 -0
- package/dist/mcp-cli-bridge.d.ts +34 -0
- package/dist/mcp-cli-bridge.js +62 -0
- package/dist/mcp-cli-bridge.js.map +1 -0
- package/dist/mcp-cli-runner.d.ts +29 -0
- package/dist/mcp-cli-runner.js +44 -0
- package/dist/mcp-cli-runner.js.map +1 -0
- package/dist/mcp-cli-tools.d.ts +125 -0
- package/dist/mcp-cli-tools.js +680 -0
- package/dist/mcp-cli-tools.js.map +1 -0
- package/dist/mcp-paths.d.ts +28 -0
- package/dist/mcp-paths.js +76 -0
- package/dist/mcp-paths.js.map +1 -0
- package/dist/mission-actors.d.ts +40 -0
- package/dist/mission-actors.js +72 -0
- package/dist/mission-actors.js.map +1 -0
- package/dist/mission-api.d.ts +35 -0
- package/dist/mission-api.js +58 -2
- package/dist/mission-api.js.map +1 -1
- package/dist/mission-cli.d.ts +4 -0
- package/dist/mission-cli.js +247 -0
- package/dist/mission-cli.js.map +1 -0
- package/dist/mission-exit.d.ts +22 -0
- package/dist/mission-exit.js +26 -0
- package/dist/mission-exit.js.map +1 -0
- package/dist/mission-fixtures.d.ts +225 -0
- package/dist/mission-fixtures.js +828 -0
- package/dist/mission-fixtures.js.map +1 -0
- package/dist/mission-journal.d.ts +60 -0
- package/dist/mission-journal.js +115 -0
- package/dist/mission-journal.js.map +1 -0
- package/dist/mission-queue-cli.d.ts +10 -0
- package/dist/mission-queue-cli.js +198 -0
- package/dist/mission-queue-cli.js.map +1 -0
- package/dist/mission-queue-runner.d.ts +126 -0
- package/dist/mission-queue-runner.js +397 -0
- package/dist/mission-queue-runner.js.map +1 -0
- package/dist/multi-run-cli.d.ts +37 -0
- package/dist/multi-run-cli.js +110 -0
- package/dist/multi-run-cli.js.map +1 -0
- package/dist/multi-run.d.ts +339 -0
- package/dist/multi-run.js +619 -0
- package/dist/multi-run.js.map +1 -0
- package/dist/openrouter-call.d.ts +22 -0
- package/dist/openrouter-call.js +63 -0
- package/dist/openrouter-call.js.map +1 -0
- package/dist/profile-cli.d.ts +4 -0
- package/dist/profile-cli.js +57 -0
- package/dist/profile-cli.js.map +1 -0
- package/dist/program.d.ts +3 -75
- package/dist/program.js +119 -1921
- package/dist/program.js.map +1 -1
- package/dist/project-dir.d.ts +96 -0
- package/dist/project-dir.js +220 -0
- package/dist/project-dir.js.map +1 -0
- package/dist/record-api.d.ts +10 -6
- package/dist/record-api.js +39 -28
- package/dist/record-api.js.map +1 -1
- package/dist/record-cli.d.ts +4 -0
- package/dist/record-cli.js +64 -0
- package/dist/record-cli.js.map +1 -0
- package/dist/recording-cli.d.ts +4 -0
- package/dist/recording-cli.js +248 -0
- package/dist/recording-cli.js.map +1 -0
- package/dist/regression-api.d.ts +101 -0
- package/dist/regression-api.js +384 -5
- package/dist/regression-api.js.map +1 -1
- package/dist/regression-cli.d.ts +4 -0
- package/dist/regression-cli.js +232 -0
- package/dist/regression-cli.js.map +1 -0
- package/dist/report-api.d.ts +146 -0
- package/dist/report-api.js +430 -0
- package/dist/report-api.js.map +1 -0
- package/dist/report-cli.d.ts +12 -0
- package/dist/report-cli.js +147 -0
- package/dist/report-cli.js.map +1 -0
- package/dist/result-schema.d.ts +22 -0
- package/dist/result-schema.js +28 -0
- package/dist/result-schema.js.map +1 -0
- package/dist/run-index.d.ts +29 -0
- package/dist/run-index.js +70 -0
- package/dist/run-index.js.map +1 -0
- package/dist/run-screenshots.d.ts +123 -0
- package/dist/run-screenshots.js +229 -0
- package/dist/run-screenshots.js.map +1 -0
- package/dist/runtime-env.d.ts +8 -0
- package/dist/runtime-env.js +22 -0
- package/dist/runtime-env.js.map +1 -0
- package/dist/secret-args.d.ts +23 -0
- package/dist/secret-args.js +45 -0
- package/dist/secret-args.js.map +1 -0
- package/dist/serve-cli.d.ts +4 -0
- package/dist/serve-cli.js +95 -0
- package/dist/serve-cli.js.map +1 -0
- package/dist/server-log-verify.d.ts +34 -0
- package/dist/server-log-verify.js +115 -0
- package/dist/server-log-verify.js.map +1 -0
- package/dist/session-check.d.ts +14 -0
- package/dist/session-check.js +44 -0
- package/dist/session-check.js.map +1 -0
- package/dist/site-cli.d.ts +4 -0
- package/dist/site-cli.js +127 -0
- package/dist/site-cli.js.map +1 -0
- package/dist/site-gate-cli.d.ts +28 -0
- package/dist/site-gate-cli.js +58 -0
- package/dist/site-gate-cli.js.map +1 -0
- package/dist/source-api.js +18 -3
- package/dist/source-api.js.map +1 -1
- package/dist/source-cli.d.ts +4 -0
- package/dist/source-cli.js +284 -0
- package/dist/source-cli.js.map +1 -0
- package/dist/source-run-api.d.ts +27 -1
- package/dist/source-run-api.js +22 -11
- package/dist/source-run-api.js.map +1 -1
- package/dist/storage-state-snapshot.d.ts +50 -0
- package/dist/storage-state-snapshot.js +87 -0
- package/dist/storage-state-snapshot.js.map +1 -0
- package/dist/suite-explore-options.d.ts +289 -0
- package/dist/suite-explore-options.js +140 -0
- package/dist/suite-explore-options.js.map +1 -0
- package/dist/target-config.d.ts +109 -0
- package/dist/target-config.js +216 -0
- package/dist/target-config.js.map +1 -0
- package/dist/transcript-file.d.ts +12 -0
- package/dist/transcript-file.js +23 -0
- package/dist/transcript-file.js.map +1 -0
- package/dist/ui-api.d.ts +3 -0
- package/dist/ui-api.js +7 -1
- package/dist/ui-api.js.map +1 -1
- package/dist/usability-capture.d.ts +43 -0
- package/dist/usability-capture.js +326 -0
- package/dist/usability-capture.js.map +1 -0
- package/dist/usage-config.d.ts +36 -0
- package/dist/usage-config.js +114 -0
- package/dist/usage-config.js.map +1 -0
- package/dist/ux-api.d.ts +3 -75
- package/dist/ux-api.js +3 -220
- package/dist/ux-api.js.map +1 -1
- package/dist/ux-cli.d.ts +4 -0
- package/dist/ux-cli.js +102 -0
- package/dist/ux-cli.js.map +1 -0
- package/dist/ux-config.d.ts +29 -0
- package/dist/ux-config.js +103 -0
- package/dist/ux-config.js.map +1 -0
- package/dist/ux-evidence.d.ts +54 -0
- package/dist/ux-evidence.js +197 -0
- package/dist/ux-evidence.js.map +1 -0
- package/dist/ux-review.d.ts +112 -0
- package/dist/ux-review.js +151 -0
- package/dist/ux-review.js.map +1 -0
- package/dist/ux-usability.d.ts +238 -0
- package/dist/ux-usability.js +494 -0
- package/dist/ux-usability.js.map +1 -0
- package/dist/verify-fix-api.d.ts +173 -0
- package/dist/verify-fix-api.js +454 -0
- package/dist/verify-fix-api.js.map +1 -0
- package/dist/verify-fix-cli.d.ts +4 -0
- package/dist/verify-fix-cli.js +110 -0
- package/dist/verify-fix-cli.js.map +1 -0
- package/package.json +19 -14
- package/skills/jevitate-ci-check/SKILL.md +77 -0
- package/skills/jevitate-demo/SKILL.md +61 -0
- package/skills/jevitate-explore/SKILL.md +88 -87
- package/skills/jevitate-getting-started/SKILL.md +88 -0
- package/skills/jevitate-load-test/SKILL.md +11 -2
- package/skills/jevitate-mission-scope/SKILL.md +19 -11
- package/skills/jevitate-record/SKILL.md +23 -5
- package/skills/jevitate-run-journey/SKILL.md +61 -17
- package/skills/jevitate-sources/SKILL.md +57 -14
- package/skills/jevitate-ux-review/SKILL.md +106 -16
- package/skills/jevitate-verify-fix/SKILL.md +76 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import { sessionLostReason } from "./session-check.js";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { MISSION_EXIT_CODES, MISSION_OUTCOMES, combineOutcomes, foldGoalOutcome, isGoalOutcome, } from "@jevitate/domain";
|
|
5
|
+
import { sumUsage, usageCountsFrom } from "@jevitate/ai-core";
|
|
6
|
+
import { normalizeRoute } from "@jevitate/explore";
|
|
7
|
+
import { currentEngineInfo } from "./engine.js";
|
|
8
|
+
/**
|
|
9
|
+
* Multi-run orchestration over the existing explore strategies (#141 repeat-and-vote, #143 persona
|
|
10
|
+
* matrix). One orchestrator runs the SAME mission N times — once per persona (storageState) and
|
|
11
|
+
* `--repeat` times within each — strictly SEQUENTIALLY (never concurrently: conversational and
|
|
12
|
+
* stateful missions share server-side state), each run in a fresh browser context (every run is a
|
|
13
|
+
* separate mission, which opens its own context). It then aggregates the runs with code only:
|
|
14
|
+
*
|
|
15
|
+
* - repeat-and-vote (#141): a finding is kept when it recurs in ≥ k of N runs; the rest are
|
|
16
|
+
* reported as `flaky` (seen, not counted). The overall outcome is the agreed one (the most
|
|
17
|
+
* common per-run outcome, when it was reached by ≥ k runs), else `intermittent`.
|
|
18
|
+
* - persona matrix (#143): per-persona outcome, findings, requests (method + templated path →
|
|
19
|
+
* status) and visible controls, plus an ADVISORY diff: requests one persona made that another
|
|
20
|
+
* did not, status differences for the same request (a 401/403 against a 2xx is a candidate RBAC
|
|
21
|
+
* finding), controls visible to one persona only, and each persona's outcome.
|
|
22
|
+
*
|
|
23
|
+
* Storage-state CONTENTS are never read, logged or copied here — only their paths travel.
|
|
24
|
+
*/
|
|
25
|
+
// ---------- plan (flags) ----------
|
|
26
|
+
export class MultiRunArgsError extends Error {
|
|
27
|
+
code = "E_EXPLORE_ARGS";
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "MultiRunArgsError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Upper bound on `--repeat`: each run is a whole mission, run one after another. */
|
|
34
|
+
export const MAX_REPEAT = 20;
|
|
35
|
+
const PERSONA_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
36
|
+
function persona(name, storageState, base, noun = "persona") {
|
|
37
|
+
if (!PERSONA_NAME.test(name)) {
|
|
38
|
+
throw new MultiRunArgsError(`${noun} name ${JSON.stringify(name)} must be 1-64 of [A-Za-z0-9_.-], starting alphanumeric`);
|
|
39
|
+
}
|
|
40
|
+
if (storageState.trim() === "")
|
|
41
|
+
throw new MultiRunArgsError(`${noun} ${name}: storage state path is empty`);
|
|
42
|
+
const path = isAbsolute(storageState) ? storageState : resolve(base, storageState);
|
|
43
|
+
if (!existsSync(path))
|
|
44
|
+
throw new MultiRunArgsError(`${noun} ${name}: storage state not found: ${path}`);
|
|
45
|
+
return { name, storageState: path };
|
|
46
|
+
}
|
|
47
|
+
/** `--persona <name>=<storageState>`: the path is resolved against the working directory. */
|
|
48
|
+
export function parsePersonaSpec(spec, cwd = process.cwd()) {
|
|
49
|
+
const eq = spec.indexOf("=");
|
|
50
|
+
if (eq <= 0)
|
|
51
|
+
throw new MultiRunArgsError(`--persona must be <name>=<storageState>, got ${JSON.stringify(spec)}`);
|
|
52
|
+
return persona(spec.slice(0, eq), spec.slice(eq + 1), cwd);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `--actor <name>=<storageState>` (#147): the same `<name>=<storageState>` shape and checks as a
|
|
56
|
+
* persona (the file must exist; its contents are never read here).
|
|
57
|
+
*/
|
|
58
|
+
export function parseActorSpec(spec, cwd = process.cwd()) {
|
|
59
|
+
const eq = spec.indexOf("=");
|
|
60
|
+
if (eq <= 0)
|
|
61
|
+
throw new MultiRunArgsError(`--actor must be <name>=<storageState>, got ${JSON.stringify(spec)}`);
|
|
62
|
+
return persona(spec.slice(0, eq), spec.slice(eq + 1), cwd, "actor");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `--personas <file>`: JSON, either `{"admin": "admin.json", "sales": "sales.json"}` or
|
|
66
|
+
* `{"personas": [{"name": "admin", "storageState": "admin.json"}]}` (a bare array also works).
|
|
67
|
+
* Relative paths resolve against the personas file's own directory.
|
|
68
|
+
*/
|
|
69
|
+
export function loadPersonasFile(path) {
|
|
70
|
+
if (!existsSync(path))
|
|
71
|
+
throw new MultiRunArgsError(`personas file not found: ${path}`);
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
throw new MultiRunArgsError(`personas file ${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
78
|
+
}
|
|
79
|
+
const base = dirname(resolve(path));
|
|
80
|
+
const list = Array.isArray(raw) ? raw : isRecord(raw) && Array.isArray(raw.personas) ? raw.personas : null;
|
|
81
|
+
if (list !== null) {
|
|
82
|
+
return list.map((p, i) => {
|
|
83
|
+
if (!isRecord(p) || typeof p.name !== "string" || typeof p.storageState !== "string") {
|
|
84
|
+
throw new MultiRunArgsError(`personas file ${path}: entry ${i} must be {"name": string, "storageState": string}`);
|
|
85
|
+
}
|
|
86
|
+
return persona(p.name, p.storageState, base);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (isRecord(raw)) {
|
|
90
|
+
return Object.entries(raw).map(([name, state]) => {
|
|
91
|
+
if (typeof state !== "string")
|
|
92
|
+
throw new MultiRunArgsError(`personas file ${path}: ${name} must map to a storage state path`);
|
|
93
|
+
return persona(name, state, base);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
throw new MultiRunArgsError(`personas file ${path} must be an object or an array of personas`);
|
|
97
|
+
}
|
|
98
|
+
/** Whether the flags ask for a multi-run at all. */
|
|
99
|
+
export function wantsMultiRun(f) {
|
|
100
|
+
return f.repeat !== undefined || f.minAgreement !== undefined || f.persona.length > 0 || f.personas !== undefined;
|
|
101
|
+
}
|
|
102
|
+
function positiveInt(v, flag) {
|
|
103
|
+
const n = Number(v);
|
|
104
|
+
if (!Number.isInteger(n) || n < 1)
|
|
105
|
+
throw new MultiRunArgsError(`${flag} must be a positive integer, got ${JSON.stringify(v)}`);
|
|
106
|
+
return n;
|
|
107
|
+
}
|
|
108
|
+
/** Validates the multi-run flags (before any browser opens). Default k = a strict majority of N. */
|
|
109
|
+
export function resolveMultiRunPlan(f, cwd = process.cwd()) {
|
|
110
|
+
const repeat = f.repeat === undefined ? 1 : positiveInt(f.repeat, "--repeat");
|
|
111
|
+
if (repeat > MAX_REPEAT)
|
|
112
|
+
throw new MultiRunArgsError(`--repeat must be at most ${MAX_REPEAT}, got ${repeat}`);
|
|
113
|
+
const minAgreement = f.minAgreement === undefined ? Math.floor(repeat / 2) + 1 : positiveInt(f.minAgreement, "--min-agreement");
|
|
114
|
+
if (minAgreement > repeat)
|
|
115
|
+
throw new MultiRunArgsError(`--min-agreement (${minAgreement}) cannot exceed --repeat (${repeat})`);
|
|
116
|
+
const personas = [...f.persona.map((s) => parsePersonaSpec(s, cwd)), ...(f.personas === undefined ? [] : loadPersonasFile(f.personas))];
|
|
117
|
+
if (personas.length > 0) {
|
|
118
|
+
if (f.storageState !== undefined) {
|
|
119
|
+
throw new MultiRunArgsError("--storage-state cannot be combined with --persona/--personas (each persona brings its own)");
|
|
120
|
+
}
|
|
121
|
+
if (f.saveStorageState !== undefined) {
|
|
122
|
+
throw new MultiRunArgsError("--save-storage-state cannot be combined with --persona/--personas (one file cannot hold every persona)");
|
|
123
|
+
}
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
for (const p of personas) {
|
|
126
|
+
if (seen.has(p.name))
|
|
127
|
+
throw new MultiRunArgsError(`persona ${p.name} is declared twice`);
|
|
128
|
+
seen.add(p.name);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { repeat, minAgreement, personas: personas.length > 0 ? personas : null };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* THE cross-run finding identity (#141). Kept deliberately small and self-contained so it can be
|
|
135
|
+
* swapped for the shared finding-identity model of `jevitate report`/`diff`/`check` (#137–#139):
|
|
136
|
+
* the engine's own fingerprint (defect / hang / invariant / signal) plus the templated route; a UX
|
|
137
|
+
* finding (no fingerprint) is its rubric item + route + implicated controls.
|
|
138
|
+
*/
|
|
139
|
+
export function findingIdentity(f) {
|
|
140
|
+
const route = normalizeRoute(f.route);
|
|
141
|
+
if (f.fingerprint !== undefined)
|
|
142
|
+
return `${f.kind}:${f.fingerprint}@${route}`;
|
|
143
|
+
return `${f.kind}:${f.rubricItemId ?? f.title}@${route}[${[...(f.controls ?? [])].sort().join("|")}]`;
|
|
144
|
+
}
|
|
145
|
+
function isRecord(v) {
|
|
146
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
147
|
+
}
|
|
148
|
+
function str(v, fallback = "") {
|
|
149
|
+
return typeof v === "string" ? v : fallback;
|
|
150
|
+
}
|
|
151
|
+
function routeOf(item) {
|
|
152
|
+
if (typeof item.route === "string")
|
|
153
|
+
return item.route;
|
|
154
|
+
if (typeof item.url === "string")
|
|
155
|
+
return normalizeRoute(item.url);
|
|
156
|
+
return "/";
|
|
157
|
+
}
|
|
158
|
+
/** Every finding a run's result carries, whatever its strategy. */
|
|
159
|
+
export function extractRunFindings(data) {
|
|
160
|
+
if (!isRecord(data))
|
|
161
|
+
return [];
|
|
162
|
+
const out = [];
|
|
163
|
+
const fingerprinted = (list, kindOf) => {
|
|
164
|
+
if (!Array.isArray(list))
|
|
165
|
+
return;
|
|
166
|
+
for (const item of list) {
|
|
167
|
+
if (!isRecord(item) || typeof item.fingerprint !== "string")
|
|
168
|
+
continue;
|
|
169
|
+
out.push({ kind: kindOf(item), fingerprint: item.fingerprint, route: routeOf(item), title: str(item.title, item.fingerprint) });
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
// Adversarial defects (hard signals + invariants), declared-invariant defects, hangs, advisories.
|
|
173
|
+
fingerprinted(data.defects, (d) => (d.kind === "invariant" ? "invariant" : `defect/${str(d.kind, "unknown")}`));
|
|
174
|
+
fingerprinted(data.hangs, () => "hang");
|
|
175
|
+
fingerprinted(data.advisories, (a) => `advisory/${str(a.kind, "signal")}`);
|
|
176
|
+
// Coverage: states the looksBroken judgment flagged, keyed by their state fingerprint.
|
|
177
|
+
if (isRecord(data.coverage) && Array.isArray(data.coverage.defects)) {
|
|
178
|
+
for (const d of data.coverage.defects) {
|
|
179
|
+
if (!isRecord(d) || typeof d.stateFingerprint !== "string")
|
|
180
|
+
continue;
|
|
181
|
+
out.push({ kind: "coverage-defect", fingerprint: d.stateFingerprint, route: routeOf(d), title: str(d.reason, "looks broken") });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Usability: UX findings (advisory) — identity by rubric item, route and implicated controls.
|
|
185
|
+
if (isRecord(data.report) && Array.isArray(data.report.findings)) {
|
|
186
|
+
for (const f of data.report.findings) {
|
|
187
|
+
if (!isRecord(f) || typeof f.rubricItemId !== "string")
|
|
188
|
+
continue;
|
|
189
|
+
const controls = Array.isArray(f.controls) ? f.controls.filter((c) => typeof c === "string") : [];
|
|
190
|
+
out.push({ kind: "ux", route: routeOf(f), title: str(f.observation, f.rubricItemId), rubricItemId: f.rubricItemId, controls });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
function isMissionOutcome(v) {
|
|
196
|
+
return typeof v === "string" && MISSION_OUTCOMES.includes(v);
|
|
197
|
+
}
|
|
198
|
+
/** A goal run's own ending (#217): its `goalOutcome`, else (an older result) its `outcome`. */
|
|
199
|
+
function goalOutcomeOf(data) {
|
|
200
|
+
if (isGoalOutcome(data.goalOutcome))
|
|
201
|
+
return data.goalOutcome;
|
|
202
|
+
return isGoalOutcome(data.outcome) ? data.outcome : undefined;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* #226: a run's canonical verdict (the #217 contract): its own `missionOutcome`; else a goal run's
|
|
206
|
+
* ending folded (`GOAL_OUTCOME_FOLD`), or another strategy's `outcome` when it already is one; else
|
|
207
|
+
* `crashed` — a run that says nothing readable proves nothing.
|
|
208
|
+
*/
|
|
209
|
+
export function runMissionOutcomeOf(strategy, data) {
|
|
210
|
+
if (!isRecord(data))
|
|
211
|
+
return "crashed";
|
|
212
|
+
if (isMissionOutcome(data.missionOutcome))
|
|
213
|
+
return data.missionOutcome;
|
|
214
|
+
if (strategy === "goal") {
|
|
215
|
+
const goal = goalOutcomeOf(data);
|
|
216
|
+
return goal === undefined ? "crashed" : foldGoalOutcome(goal);
|
|
217
|
+
}
|
|
218
|
+
return isMissionOutcome(data.outcome) ? data.outcome : "crashed";
|
|
219
|
+
}
|
|
220
|
+
/** `METHOD /templated/path` → the distinct response statuses seen (assets excluded; pending dropped). */
|
|
221
|
+
export function extractRequests(data) {
|
|
222
|
+
const out = {};
|
|
223
|
+
if (!isRecord(data) || !isRecord(data.timing) || !isRecord(data.timing.endpoints))
|
|
224
|
+
return out;
|
|
225
|
+
for (const [endpoint, stat] of Object.entries(data.timing.endpoints)) {
|
|
226
|
+
if (!isRecord(stat) || stat.kind === "asset")
|
|
227
|
+
continue;
|
|
228
|
+
const statuses = Array.isArray(stat.statuses) ? stat.statuses.filter((s) => typeof s === "number") : [];
|
|
229
|
+
out[endpoint] = [...new Set(statuses)].sort((a, b) => a - b);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
/** Every control identity the run's transcript saw (see `TranscriptEntry.controls`). */
|
|
234
|
+
export function extractControls(transcript) {
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
if (!Array.isArray(transcript))
|
|
237
|
+
return [];
|
|
238
|
+
for (const e of transcript) {
|
|
239
|
+
if (!isRecord(e) || !Array.isArray(e.controls))
|
|
240
|
+
continue;
|
|
241
|
+
for (const c of e.controls)
|
|
242
|
+
if (typeof c === "string")
|
|
243
|
+
seen.add(c);
|
|
244
|
+
}
|
|
245
|
+
return [...seen].sort();
|
|
246
|
+
}
|
|
247
|
+
/** #213: endings that say nothing about what the app shows a persona — the environment, not access. */
|
|
248
|
+
const ENVIRONMENT_FAILURES = new Set(["degraded-environment", "target-unreachable", "target-unresponsive"]);
|
|
249
|
+
/** Outcomes that mean the run itself broke — it proves nothing about the app, so it never makes a vote `intermittent`. */
|
|
250
|
+
function isBrokenRunSummary(r) {
|
|
251
|
+
return !r.ok || r.missionOutcome === "crashed" || r.missionOutcome === "inconclusive";
|
|
252
|
+
}
|
|
253
|
+
/** The most common value when ≥ k runs reached it and it is not tied; else undefined. */
|
|
254
|
+
function agreed(values, k) {
|
|
255
|
+
const counts = new Map();
|
|
256
|
+
for (const v of values)
|
|
257
|
+
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
258
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
259
|
+
const top = ranked[0];
|
|
260
|
+
const tied = ranked[1] !== undefined && top !== undefined && ranked[1][1] === top[1];
|
|
261
|
+
return top !== undefined && top[1] >= k && !tied ? top[0] : undefined;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The vote's canonical outcome (#141, #220, #226): the most common `missionOutcome` when ≥ k runs
|
|
265
|
+
* reached it (and it is not tied); otherwise `inconclusive` when runs are still missing or a run broke
|
|
266
|
+
* (with the reason), and `intermittent` only when every run finished with a real verdict and they disagree.
|
|
267
|
+
*/
|
|
268
|
+
function voteOutcome(runs, k, planned) {
|
|
269
|
+
const top = agreed(runs.map((r) => r.missionOutcome), k);
|
|
270
|
+
if (top !== undefined)
|
|
271
|
+
return { outcome: top };
|
|
272
|
+
const broken = runs.filter(isBrokenRunSummary);
|
|
273
|
+
if (broken.length > 0) {
|
|
274
|
+
const which = broken.map((r) => `run ${r.index} ${r.missionOutcome}${r.reason === undefined ? "" : ` (${r.reason})`}`).join("; ");
|
|
275
|
+
return { outcome: "inconclusive", reason: `no outcome reached ${k} of ${runs.length} run(s) because a run broke: ${which}` };
|
|
276
|
+
}
|
|
277
|
+
if (runs.length < planned) {
|
|
278
|
+
return { outcome: "inconclusive", reason: `only ${runs.length} of ${planned} run(s) finished; no outcome reached ${k} yet` };
|
|
279
|
+
}
|
|
280
|
+
return { outcome: "intermittent" };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Votes one persona's (or the mission's) runs: findings, outcome, requests and controls. `planned`
|
|
284
|
+
* is how many runs the plan has (default: the runs given) — fewer finished never reads `intermittent`.
|
|
285
|
+
*/
|
|
286
|
+
export function voteRuns(runs, k, persona = null, planned = runs.length) {
|
|
287
|
+
const n = runs.length;
|
|
288
|
+
const outcomes = {};
|
|
289
|
+
for (const r of runs)
|
|
290
|
+
outcomes[r.outcome] = (outcomes[r.outcome] ?? 0) + 1;
|
|
291
|
+
const { outcome: missionOutcome, reason } = voteOutcome(runs, k, planned);
|
|
292
|
+
// A goal cell's own ending: the goal ending ≥ k runs agreed on (when it folds onto the agreed
|
|
293
|
+
// verdict), else the canonical verdict itself (a shared outcome is a goal outcome too, #217).
|
|
294
|
+
const goalRuns = runs.filter((r) => r.goalOutcome !== undefined);
|
|
295
|
+
const goalAgreed = goalRuns.length === 0 ? undefined : agreed(goalRuns.map((r) => r.goalOutcome), k);
|
|
296
|
+
const goalOutcome = goalRuns.length === 0 ? undefined : goalAgreed !== undefined && foldGoalOutcome(goalAgreed) === missionOutcome ? goalAgreed : missionOutcome;
|
|
297
|
+
const outcome = goalOutcome ?? missionOutcome;
|
|
298
|
+
const byId = new Map();
|
|
299
|
+
for (const r of runs) {
|
|
300
|
+
for (const f of r.findings) {
|
|
301
|
+
const id = findingIdentity(f);
|
|
302
|
+
const cur = byId.get(id) ?? { f, runs: [] };
|
|
303
|
+
if (!cur.runs.includes(r.index))
|
|
304
|
+
cur.runs.push(r.index);
|
|
305
|
+
byId.set(id, cur);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const all = [...byId.entries()].map(([id, { f, runs: seenIn }]) => ({
|
|
309
|
+
id,
|
|
310
|
+
kind: f.kind,
|
|
311
|
+
...(f.fingerprint === undefined ? {} : { fingerprint: f.fingerprint }),
|
|
312
|
+
route: normalizeRoute(f.route),
|
|
313
|
+
title: f.title,
|
|
314
|
+
stability: `${seenIn.length}/${n}`,
|
|
315
|
+
seen: seenIn.length,
|
|
316
|
+
of: n,
|
|
317
|
+
runs: seenIn.sort((a, b) => a - b),
|
|
318
|
+
status: seenIn.length >= k ? "agreed" : "flaky",
|
|
319
|
+
}));
|
|
320
|
+
all.sort((a, b) => b.seen - a.seen || a.id.localeCompare(b.id));
|
|
321
|
+
const requestRuns = new Map();
|
|
322
|
+
const controlRuns = new Map();
|
|
323
|
+
for (const r of runs) {
|
|
324
|
+
for (const [endpoint, statuses] of Object.entries(r.requests)) {
|
|
325
|
+
const cur = requestRuns.get(endpoint) ?? { runs: 0, statuses: new Set() };
|
|
326
|
+
cur.runs += 1;
|
|
327
|
+
for (const s of statuses)
|
|
328
|
+
cur.statuses.add(s);
|
|
329
|
+
requestRuns.set(endpoint, cur);
|
|
330
|
+
}
|
|
331
|
+
for (const c of new Set(r.controls))
|
|
332
|
+
controlRuns.set(c, (controlRuns.get(c) ?? 0) + 1);
|
|
333
|
+
}
|
|
334
|
+
const requests = {};
|
|
335
|
+
for (const [endpoint, v] of [...requestRuns.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
336
|
+
if (v.runs >= k)
|
|
337
|
+
requests[endpoint] = [...v.statuses].sort((a, b) => a - b);
|
|
338
|
+
}
|
|
339
|
+
const controls = [...controlRuns.entries()].filter(([, c]) => c >= k).map(([c]) => c).sort();
|
|
340
|
+
const notObserved = n > 0 && runs.every((r) => r.failureKind !== undefined && ENVIRONMENT_FAILURES.has(r.failureKind))
|
|
341
|
+
? (runs[0].reason ?? runs[0].failureKind)
|
|
342
|
+
: undefined;
|
|
343
|
+
const lost = runs.filter((r) => r.sessionLost !== undefined);
|
|
344
|
+
const sessionLost = lost.length === 0 ? undefined : `run(s) ${lost.map((r) => r.index).join(", ")} of ${n}: ${lost[0].sessionLost}`;
|
|
345
|
+
return {
|
|
346
|
+
persona: persona?.name ?? null,
|
|
347
|
+
...(persona === null ? {} : { storageStatePath: persona.storageState }),
|
|
348
|
+
...(sessionLost === undefined ? {} : { sessionLost }),
|
|
349
|
+
outcome,
|
|
350
|
+
missionOutcome,
|
|
351
|
+
...(goalOutcome === undefined ? {} : { goalOutcome }),
|
|
352
|
+
...(reason === undefined ? {} : { reason }),
|
|
353
|
+
exitCode: MISSION_EXIT_CODES[missionOutcome],
|
|
354
|
+
outcomes,
|
|
355
|
+
runs,
|
|
356
|
+
findings: all.filter((f) => f.status === "agreed"),
|
|
357
|
+
flaky: all.filter((f) => f.status === "flaky"),
|
|
358
|
+
requests,
|
|
359
|
+
controls,
|
|
360
|
+
...(notObserved === undefined ? {} : { notObserved }),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const is2xx = (s) => s >= 200 && s < 300;
|
|
364
|
+
const isDenied = (s) => s === 401 || s === 403;
|
|
365
|
+
function presence(sets) {
|
|
366
|
+
const names = [...sets.keys()];
|
|
367
|
+
const items = new Set();
|
|
368
|
+
for (const s of sets.values())
|
|
369
|
+
for (const i of s)
|
|
370
|
+
items.add(i);
|
|
371
|
+
const out = [];
|
|
372
|
+
for (const item of [...items].sort()) {
|
|
373
|
+
const presentFor = names.filter((n) => sets.get(n)?.has(item) === true);
|
|
374
|
+
if (presentFor.length === names.length)
|
|
375
|
+
continue;
|
|
376
|
+
out.push({ item, presentFor, absentFor: names.filter((n) => !presentFor.includes(n)) });
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
/** The persona matrix's diff section (#143): pure, over the per-persona (voted) cells. */
|
|
381
|
+
export function diffPersonas(cells) {
|
|
382
|
+
const personaCells = cells.filter((c) => c.persona !== null);
|
|
383
|
+
// #213: a persona whose runs never observed the app (a starved host, a target that did not load)
|
|
384
|
+
// has no requests or controls to compare — its absence is the environment, not an access difference.
|
|
385
|
+
const named = personaCells.filter((c) => c.notObserved === undefined);
|
|
386
|
+
const notCompared = personaCells.flatMap((c) => (c.notObserved === undefined ? [] : [{ persona: c.persona, reason: c.notObserved }]));
|
|
387
|
+
const requestSets = new Map(named.map((c) => [c.persona, new Set(Object.keys(c.requests))]));
|
|
388
|
+
const controlSets = new Map(named.map((c) => [c.persona, new Set(c.controls)]));
|
|
389
|
+
const statusDiffs = [];
|
|
390
|
+
const rbacCandidates = [];
|
|
391
|
+
const shared = new Set();
|
|
392
|
+
for (const c of named)
|
|
393
|
+
for (const r of Object.keys(c.requests))
|
|
394
|
+
shared.add(r);
|
|
395
|
+
for (const request of [...shared].sort()) {
|
|
396
|
+
const having = named.filter((c) => c.requests[request] !== undefined && c.requests[request].length > 0);
|
|
397
|
+
if (having.length < 2)
|
|
398
|
+
continue;
|
|
399
|
+
const key = (c) => (c.requests[request] ?? []).join(",");
|
|
400
|
+
if (new Set(having.map(key)).size < 2)
|
|
401
|
+
continue;
|
|
402
|
+
const statuses = {};
|
|
403
|
+
for (const c of having)
|
|
404
|
+
statuses[c.persona] = [...(c.requests[request] ?? [])];
|
|
405
|
+
statusDiffs.push({ request, statuses });
|
|
406
|
+
const denied = {};
|
|
407
|
+
const allowed = [];
|
|
408
|
+
for (const c of having) {
|
|
409
|
+
const ss = c.requests[request] ?? [];
|
|
410
|
+
if (ss.some(is2xx))
|
|
411
|
+
allowed.push(c.persona);
|
|
412
|
+
else if (ss.some(isDenied))
|
|
413
|
+
denied[c.persona] = ss.filter(isDenied);
|
|
414
|
+
}
|
|
415
|
+
if (allowed.length > 0 && Object.keys(denied).length > 0) {
|
|
416
|
+
const deniedText = Object.entries(denied)
|
|
417
|
+
.map(([p, ss]) => `${ss.join("/")} for ${p}`)
|
|
418
|
+
.join(", ");
|
|
419
|
+
rbacCandidates.push({ request, denied, allowed, title: `${request}: ${deniedText}; 2xx for ${allowed.join(", ")}` });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const outcomes = {};
|
|
423
|
+
for (const c of personaCells)
|
|
424
|
+
outcomes[c.persona] = c.missionOutcome;
|
|
425
|
+
// #213: session loss is flagged for EVERY persona (observed or not).
|
|
426
|
+
const sessionLost = {};
|
|
427
|
+
for (const c of personaCells)
|
|
428
|
+
if (c.sessionLost !== undefined)
|
|
429
|
+
sessionLost[c.persona] = c.sessionLost;
|
|
430
|
+
return {
|
|
431
|
+
advisory: true,
|
|
432
|
+
...(Object.keys(sessionLost).length === 0 ? {} : { sessionLost }),
|
|
433
|
+
requestsOnlyIn: presence(requestSets),
|
|
434
|
+
statusDiffs,
|
|
435
|
+
controlsOnlyIn: presence(controlSets),
|
|
436
|
+
outcomes,
|
|
437
|
+
outcomeDiffers: new Set(named.map((c) => c.missionOutcome)).size > 1,
|
|
438
|
+
rbacCandidates,
|
|
439
|
+
notCompared,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
function readTranscript(data) {
|
|
443
|
+
if (Array.isArray(data.transcript))
|
|
444
|
+
return data.transcript;
|
|
445
|
+
if (typeof data.transcriptPath !== "string")
|
|
446
|
+
return [];
|
|
447
|
+
try {
|
|
448
|
+
return JSON.parse(readFileSync(data.transcriptPath, "utf8"));
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** Why a run ended (#220): its crash's failure, else its own `reason`/`failure` — never invented. */
|
|
455
|
+
function runReasonOf(data) {
|
|
456
|
+
const failureText = (f) => isRecord(f) && typeof f.message === "string" ? `${typeof f.kind === "string" ? `${f.kind}: ` : ""}${f.message}` : undefined;
|
|
457
|
+
return (isRecord(data.crash) ? failureText(data.crash.failure) : undefined) ?? failureText(data.failure) ?? (typeof data.reason === "string" ? data.reason : undefined);
|
|
458
|
+
}
|
|
459
|
+
/** #213: the run's `failure.kind` (a crash's own failure first), when its result carries one. */
|
|
460
|
+
function runFailureKindOf(data) {
|
|
461
|
+
const kindOf = (f) => (isRecord(f) && typeof f.kind === "string" ? f.kind : undefined);
|
|
462
|
+
return (isRecord(data.crash) ? kindOf(data.crash.failure) : undefined) ?? kindOf(data.failure);
|
|
463
|
+
}
|
|
464
|
+
/** Summarizes one run's envelope (the only IO: its transcript file when the result omits it). */
|
|
465
|
+
export function summarizeRun(strategy, index, envelope, envelopePath) {
|
|
466
|
+
const base = { index, ...(envelopePath === undefined ? {} : { envelopePath }) };
|
|
467
|
+
if (!envelope.ok) {
|
|
468
|
+
return {
|
|
469
|
+
...base,
|
|
470
|
+
ok: false,
|
|
471
|
+
outcome: "crashed",
|
|
472
|
+
missionOutcome: "crashed",
|
|
473
|
+
exitCode: MISSION_EXIT_CODES.crashed,
|
|
474
|
+
findings: [],
|
|
475
|
+
requests: {},
|
|
476
|
+
controls: [],
|
|
477
|
+
error: envelope.error,
|
|
478
|
+
reason: `${envelope.error.code}: ${envelope.error.message}`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const data = isRecord(envelope.data) ? envelope.data : {};
|
|
482
|
+
const resultPath = typeof data.resultPath === "string" ? data.resultPath : typeof data.reportPath === "string" ? data.reportPath : undefined;
|
|
483
|
+
const usage = usageCountsFrom(data.usage);
|
|
484
|
+
const reason = runReasonOf(data);
|
|
485
|
+
const failureKind = runFailureKindOf(data);
|
|
486
|
+
const missionOutcome = runMissionOutcomeOf(strategy, data);
|
|
487
|
+
// #213: a result may already say so (goal); else decided here from its target + first transcript page.
|
|
488
|
+
const sessionLost = isRecord(data.sessionLost) && typeof data.sessionLost.reason === "string"
|
|
489
|
+
? data.sessionLost.reason
|
|
490
|
+
: sessionLostReason({ target: data.target, transcript: readTranscript(data) });
|
|
491
|
+
const goalOutcome = strategy === "goal" ? (goalOutcomeOf(data) ?? missionOutcome) : undefined;
|
|
492
|
+
return {
|
|
493
|
+
...base,
|
|
494
|
+
ok: true,
|
|
495
|
+
...(reason === undefined ? {} : { reason }),
|
|
496
|
+
...(failureKind === undefined ? {} : { failureKind }),
|
|
497
|
+
outcome: goalOutcome ?? missionOutcome,
|
|
498
|
+
missionOutcome,
|
|
499
|
+
...(goalOutcome === undefined ? {} : { goalOutcome }),
|
|
500
|
+
exitCode: typeof data.exitCode === "number" ? data.exitCode : MISSION_EXIT_CODES[missionOutcome],
|
|
501
|
+
findings: extractRunFindings(data),
|
|
502
|
+
requests: extractRequests(data),
|
|
503
|
+
controls: extractControls(readTranscript(data)),
|
|
504
|
+
...(resultPath === undefined ? {} : { resultPath }),
|
|
505
|
+
...(usage === undefined ? {} : { usage }),
|
|
506
|
+
...(data.answer === undefined || data.answer === null ? {} : { answer: data.answer }),
|
|
507
|
+
...(sessionLost === undefined ? {} : { sessionLost }),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function writeJson(path, value) {
|
|
511
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
512
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
513
|
+
}
|
|
514
|
+
/** Folds the voted cells into the aggregate (pure). */
|
|
515
|
+
export function aggregateCells(strategy, plan, cells, resultPath, complete, engine = currentEngineInfo()) {
|
|
516
|
+
const withPersona = (c, fs) => fs.map((f) => (c.persona === null ? f : { ...f, persona: c.persona }));
|
|
517
|
+
const personas = plan.personas !== null;
|
|
518
|
+
const outcomes = new Set(cells.map((c) => c.outcome));
|
|
519
|
+
const voted = !personas ? (cells[0]?.outcome ?? "inconclusive") : outcomes.size === 1 ? [...outcomes][0] : "mixed";
|
|
520
|
+
// #226: the canonical verdict — one persona's (or the mission's), or the most severe persona's.
|
|
521
|
+
const votedMission = cells.length === 0 ? "inconclusive" : combineOutcomes(cells.map((c) => c.missionOutcome));
|
|
522
|
+
const goals = cells.map((c) => c.goalOutcome).filter((g) => g !== undefined);
|
|
523
|
+
// #220: a multi-run with runs still pending proves nothing yet — never `intermittent`.
|
|
524
|
+
const finished = cells.reduce((n, c) => n + c.runs.length, 0);
|
|
525
|
+
const planned = plan.repeat * (plan.personas?.length ?? 1);
|
|
526
|
+
const cellReason = cells.find((c) => c.reason !== undefined)?.reason;
|
|
527
|
+
const outcome = complete ? voted : "inconclusive";
|
|
528
|
+
const missionOutcome = complete ? votedMission : "inconclusive";
|
|
529
|
+
const goalOutcome = strategy !== "goal" ? undefined : complete && goals.length === cells.length && new Set(goals).size === 1 ? goals[0] : missionOutcome;
|
|
530
|
+
const reason = complete ? (missionOutcome === "inconclusive" ? cellReason : undefined) : `incomplete: ${finished} of ${planned} run(s) finished`;
|
|
531
|
+
return {
|
|
532
|
+
kind: "multi-run",
|
|
533
|
+
strategy,
|
|
534
|
+
repeat: plan.repeat,
|
|
535
|
+
minAgreement: plan.minAgreement,
|
|
536
|
+
outcome,
|
|
537
|
+
missionOutcome,
|
|
538
|
+
...(goalOutcome === undefined ? {} : { goalOutcome }),
|
|
539
|
+
...(reason === undefined ? {} : { reason }),
|
|
540
|
+
exitCode: MISSION_EXIT_CODES[missionOutcome],
|
|
541
|
+
engine,
|
|
542
|
+
findings: cells.flatMap((c) => withPersona(c, c.findings)),
|
|
543
|
+
flaky: cells.flatMap((c) => withPersona(c, c.flaky)),
|
|
544
|
+
cells,
|
|
545
|
+
...(personas ? { diff: diffPersonas(cells) } : {}),
|
|
546
|
+
resultPath,
|
|
547
|
+
complete,
|
|
548
|
+
usage: sumUsage(cells.flatMap((c) => c.runs.map((r) => r.usage))),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Runs the plan SEQUENTIALLY — persona by persona, `repeat` runs each, one at a time (never
|
|
553
|
+
* concurrently) — writing each run's envelope next to its artifacts and rewriting the aggregate
|
|
554
|
+
* after every run (so a killed multi-run still leaves the runs it finished, marked incomplete).
|
|
555
|
+
*/
|
|
556
|
+
export async function runMultiRun(opts) {
|
|
557
|
+
const { plan, strategy, outDir } = opts;
|
|
558
|
+
const resultPath = join(outDir, "multi-run.result.json");
|
|
559
|
+
const groups = plan.personas ?? [null];
|
|
560
|
+
const cells = [];
|
|
561
|
+
// #220: what a kill signal needs to write the partial aggregate synchronously.
|
|
562
|
+
let current;
|
|
563
|
+
const onKill = (interrupted) => {
|
|
564
|
+
const partialCells = [...cells];
|
|
565
|
+
const planned = plan.repeat * (plan.personas?.length ?? 1);
|
|
566
|
+
const finished = cells.reduce((n, c) => n + c.runs.length, 0) + (current?.runs.length ?? 0);
|
|
567
|
+
const during = current === undefined ? "" : ` during run ${current.index}${current.persona === null ? "" : ` (persona ${current.persona.name})`}`;
|
|
568
|
+
if (current !== undefined) {
|
|
569
|
+
const { persona, runs, index, runDir } = current;
|
|
570
|
+
const envelope = interrupted.partial === undefined
|
|
571
|
+
? { ok: true, data: { outcome: "inconclusive", missionOutcome: "inconclusive", exitCode: interrupted.exitCode, reason: `interrupted by ${interrupted.signal} before the mission started` } }
|
|
572
|
+
: { ok: true, data: interrupted.partial };
|
|
573
|
+
const envelopePath = join(runDir, "run.envelope.json");
|
|
574
|
+
try {
|
|
575
|
+
writeJson(envelopePath, envelope);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// Best-effort: the aggregate below still records the interrupted run.
|
|
579
|
+
}
|
|
580
|
+
partialCells.push(voteRuns([...runs, summarizeRun(strategy, index, envelope, envelopePath)], plan.minAgreement, persona, plan.repeat));
|
|
581
|
+
}
|
|
582
|
+
const base = aggregateCells(strategy, plan, partialCells, resultPath, false);
|
|
583
|
+
const result = {
|
|
584
|
+
...base,
|
|
585
|
+
reason: `interrupted by ${interrupted.signal}${during}; ${finished} of ${planned} run(s) finished`,
|
|
586
|
+
interrupted: { signal: interrupted.signal },
|
|
587
|
+
exitCode: interrupted.exitCode,
|
|
588
|
+
};
|
|
589
|
+
writeJson(resultPath, result);
|
|
590
|
+
return result;
|
|
591
|
+
};
|
|
592
|
+
const disarm = opts.armKill?.(onKill);
|
|
593
|
+
try {
|
|
594
|
+
for (const p of groups) {
|
|
595
|
+
const runs = [];
|
|
596
|
+
for (let i = 1; i <= plan.repeat; i++) {
|
|
597
|
+
const runDir = join(outDir, p === null ? "" : p.name, `run-${i}`);
|
|
598
|
+
mkdirSync(runDir, { recursive: true });
|
|
599
|
+
current = { persona: p, runs, index: i, runDir };
|
|
600
|
+
// Strictly one at a time: the next run starts only after this one fully ended.
|
|
601
|
+
const envelope = await opts.runOnce({ ...(p === null ? {} : { storageState: p.storageState }), outDir: runDir });
|
|
602
|
+
const envelopePath = join(runDir, "run.envelope.json");
|
|
603
|
+
writeJson(envelopePath, envelope);
|
|
604
|
+
runs.push(summarizeRun(strategy, i, envelope, envelopePath));
|
|
605
|
+
current = undefined;
|
|
606
|
+
const partial = [...cells, voteRuns(runs, plan.minAgreement, p, plan.repeat)];
|
|
607
|
+
writeJson(resultPath, aggregateCells(strategy, plan, partial, resultPath, false));
|
|
608
|
+
}
|
|
609
|
+
cells.push(voteRuns(runs, plan.minAgreement, p, plan.repeat));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
disarm?.();
|
|
614
|
+
}
|
|
615
|
+
const result = aggregateCells(strategy, plan, cells, resultPath, true);
|
|
616
|
+
writeJson(resultPath, result);
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
619
|
+
//# sourceMappingURL=multi-run.js.map
|