@jevitate/cli 0.28.1 → 0.29.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 +1 -1
- package/dist/approval-provenance.d.ts +9 -1
- package/dist/approval-provenance.js +12 -4
- package/dist/approval-provenance.js.map +1 -1
- package/dist/author-journey-cli.js +1 -1
- package/dist/author-journey-cli.js.map +1 -1
- package/dist/bin.js +12657 -8284
- package/dist/bin.js.map +1 -1
- package/dist/build-info.generated.js +3 -3
- package/dist/catalog-analysis.js +3 -2
- package/dist/catalog-analysis.js.map +1 -1
- package/dist/catalog-bundle.js +2 -2
- package/dist/catalog-bundle.js.map +1 -1
- package/dist/check-plan.js +12 -0
- package/dist/check-plan.js.map +1 -1
- package/dist/demo-aspect-cli.d.ts +10 -0
- package/dist/demo-aspect-cli.js +4 -1
- package/dist/demo-aspect-cli.js.map +1 -1
- package/dist/engine.d.ts +8 -1
- package/dist/engine.js +16 -2
- package/dist/engine.js.map +1 -1
- package/dist/explore-adversarial.js +4 -0
- package/dist/explore-adversarial.js.map +1 -1
- package/dist/explore-cli/feature-strategy.js +1 -0
- package/dist/explore-cli/feature-strategy.js.map +1 -1
- package/dist/explore-cli/run.js +7 -0
- package/dist/explore-cli/run.js.map +1 -1
- package/dist/explore-feature.d.ts +2 -0
- package/dist/explore-feature.js +15 -8
- package/dist/explore-feature.js.map +1 -1
- package/dist/explore-goal.js +12 -0
- package/dist/explore-goal.js.map +1 -1
- package/dist/explore-specs.js +2 -2
- package/dist/explore-specs.js.map +1 -1
- package/dist/heal-probe-guard.d.ts +7 -3
- package/dist/heal-probe-guard.js +8 -2
- package/dist/heal-probe-guard.js.map +1 -1
- package/dist/http-probe.js +2 -2
- package/dist/http-probe.js.map +1 -1
- package/dist/journey-annotate-api.d.ts +5 -0
- package/dist/journey-annotate-api.js +49 -24
- package/dist/journey-annotate-api.js.map +1 -1
- package/dist/journey-annotate-cli.js +4 -1
- package/dist/journey-annotate-cli.js.map +1 -1
- package/dist/journey-api.d.ts +46 -7
- package/dist/journey-api.js +111 -43
- package/dist/journey-api.js.map +1 -1
- package/dist/journey-chain.d.ts +0 -6
- package/dist/journey-chain.js +36 -5
- package/dist/journey-chain.js.map +1 -1
- package/dist/journey-cli/chain.js +6 -0
- package/dist/journey-cli/chain.js.map +1 -1
- package/dist/journey-cli/lint.js +10 -3
- package/dist/journey-cli/lint.js.map +1 -1
- package/dist/journey-cli/run.js +39 -4
- package/dist/journey-cli/run.js.map +1 -1
- package/dist/journey-cli/verify.d.ts +8 -1
- package/dist/journey-cli/verify.js +111 -14
- package/dist/journey-cli/verify.js.map +1 -1
- package/dist/journey-cli-readiness.js +2 -0
- package/dist/journey-cli-readiness.js.map +1 -1
- package/dist/journey-demo-cli.js +2 -0
- package/dist/journey-demo-cli.js.map +1 -1
- package/dist/journey-network-checks.d.ts +4 -0
- package/dist/journey-network-checks.js +6 -2
- package/dist/journey-network-checks.js.map +1 -1
- package/dist/journey-prefix.d.ts +2 -0
- package/dist/journey-prefix.js +20 -1
- package/dist/journey-prefix.js.map +1 -1
- package/dist/journey-proof-context.d.ts +7 -1
- package/dist/journey-proof-context.js +19 -1
- package/dist/journey-proof-context.js.map +1 -1
- package/dist/journey-proof-waiver-cli.js +6 -3
- package/dist/journey-proof-waiver-cli.js.map +1 -1
- package/dist/journey-readiness.js +4 -0
- package/dist/journey-readiness.js.map +1 -1
- package/dist/journey-review-api.js +23 -0
- package/dist/journey-review-api.js.map +1 -1
- package/dist/journey-review.d.ts +30 -10
- package/dist/journey-review.js +33 -39
- package/dist/journey-review.js.map +1 -1
- package/dist/journey-run-record.d.ts +91 -0
- package/dist/journey-run-record.js +239 -0
- package/dist/journey-run-record.js.map +1 -0
- package/dist/journey-stale-api.d.ts +7 -0
- package/dist/journey-stale-api.js +50 -6
- package/dist/journey-stale-api.js.map +1 -1
- package/dist/journey-verify-checkpoint.d.ts +656 -0
- package/dist/journey-verify-checkpoint.js +923 -0
- package/dist/journey-verify-checkpoint.js.map +1 -0
- package/dist/journey-verify.d.ts +84 -3
- package/dist/journey-verify.js +1387 -110
- package/dist/journey-verify.js.map +1 -1
- package/dist/kill-signal.d.ts +24 -2
- package/dist/kill-signal.js +39 -11
- package/dist/kill-signal.js.map +1 -1
- package/dist/load-api.js +31 -2
- package/dist/load-api.js.map +1 -1
- package/dist/load-cli.js +6 -0
- package/dist/load-cli.js.map +1 -1
- package/dist/logs-retention.js +28 -3
- package/dist/logs-retention.js.map +1 -1
- package/dist/mask-values.d.ts +5 -0
- package/dist/mask-values.js +12 -0
- package/dist/mask-values.js.map +1 -0
- package/dist/mcp-api.d.ts +2 -0
- package/dist/mcp-api.js +19 -3
- package/dist/mcp-api.js.map +1 -1
- package/dist/mcp-cli-runner.d.ts +7 -1
- package/dist/mcp-cli-runner.js +30 -3
- package/dist/mcp-cli-runner.js.map +1 -1
- package/dist/mcp-cli-tools.d.ts +9 -1
- package/dist/mcp-cli-tools.js +29 -4
- package/dist/mcp-cli-tools.js.map +1 -1
- package/dist/mcp-paths.js +11 -0
- package/dist/mcp-paths.js.map +1 -1
- package/dist/mcp-run-guard.d.ts +79 -0
- package/dist/mcp-run-guard.js +371 -0
- package/dist/mcp-run-guard.js.map +1 -0
- package/dist/mcp-tools/cli.js +1 -1
- package/dist/mcp-tools/cli.js.map +1 -1
- package/dist/mcp-tools/findings.js +12 -1
- package/dist/mcp-tools/findings.js.map +1 -1
- package/dist/mcp-tools/journey.js +25 -8
- package/dist/mcp-tools/journey.js.map +1 -1
- package/dist/mission-fixtures.d.ts +3 -1
- package/dist/mission-fixtures.js +23 -6
- package/dist/mission-fixtures.js.map +1 -1
- package/dist/mutation-proof-policy.d.ts +2 -0
- package/dist/mutation-proof-policy.js +2 -2
- package/dist/mutation-proof-policy.js.map +1 -1
- package/dist/program.d.ts +6 -0
- package/dist/program.js +6 -9
- package/dist/program.js.map +1 -1
- package/dist/project-dir.d.ts +5 -1
- package/dist/project-dir.js +54 -5
- package/dist/project-dir.js.map +1 -1
- package/dist/readiness.js +3 -1
- package/dist/readiness.js.map +1 -1
- package/dist/regression-api.d.ts +5 -0
- package/dist/regression-api.js +17 -10
- package/dist/regression-api.js.map +1 -1
- package/dist/regression-cli.js +14 -1
- package/dist/regression-cli.js.map +1 -1
- package/dist/report-api.js +6 -0
- package/dist/report-api.js.map +1 -1
- package/dist/resource-preflight.d.ts +2 -1
- package/dist/resource-preflight.js +2 -1
- package/dist/resource-preflight.js.map +1 -1
- package/dist/secret-params.d.ts +82 -0
- package/dist/secret-params.js +234 -0
- package/dist/secret-params.js.map +1 -0
- package/dist/self-heal-adapter.d.ts +2 -2
- package/dist/self-heal-adapter.js +7 -9
- package/dist/self-heal-adapter.js.map +1 -1
- package/dist/source-cli.js +3 -1
- package/dist/source-cli.js.map +1 -1
- package/dist/source-run-api.d.ts +2 -0
- package/dist/source-run-api.js +21 -2
- package/dist/source-run-api.js.map +1 -1
- package/dist/target-config.d.ts +39 -0
- package/dist/target-config.js +76 -1
- package/dist/target-config.js.map +1 -1
- package/dist/terminal-result.d.ts +181 -0
- package/dist/terminal-result.js +609 -0
- package/dist/terminal-result.js.map +1 -0
- package/dist/usability-capture.js +2 -2
- package/dist/usability-capture.js.map +1 -1
- package/dist/ux-usability/hang.js +2 -0
- package/dist/ux-usability/hang.js.map +1 -1
- package/dist/ux-usability.js +6 -0
- package/dist/ux-usability.js.map +1 -1
- package/dist/verify-fix-api.d.ts +43 -0
- package/dist/verify-fix-api.js +138 -10
- package/dist/verify-fix-api.js.map +1 -1
- package/dist/verify-fix-cli.d.ts +6 -1
- package/dist/verify-fix-cli.js +65 -11
- package/dist/verify-fix-cli.js.map +1 -1
- package/package.json +11 -11
- package/skills/jevitate-run-journey/SKILL.md +23 -3
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmdirSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { clock } from "@jevitate/domain";
|
|
7
|
+
import { assertionSiteKey, MAX_TARGETED_RELAXATIONS, classifyMutation, mutationErrored, needsRelaxedReplay, sitesReachedEarly, thinSites, } from "@jevitate/journey";
|
|
8
|
+
import { systemProcessTable } from "@jevitate/playwright";
|
|
9
|
+
import { homeDataRoot } from "./project-dir.js";
|
|
10
|
+
/**
|
|
11
|
+
* #787: the durable progress of `journey verify --mutate`, one small file per Journey and journey
|
|
12
|
+
* store under `<project>/.jevitate/logs/verify-progress/`, replaced atomically as the proof moves.
|
|
13
|
+
* A "pair" here is one planned mutation with all its replays (first, relaxed, control, targeted),
|
|
14
|
+
* since that is the unit of work a proof finishes: `pairs` in the plan are site↔mutation links.
|
|
15
|
+
*
|
|
16
|
+
* What it stores is what a resumed verdict needs and nothing the app or the operator wrote: outcome
|
|
17
|
+
* classes, structured failed sites, site keys, mutation ids, timings and counts. Never a `reason`, a
|
|
18
|
+
* blocked write's path, hook text, a parameter value or a Journey value. Hooks, params, origins and
|
|
19
|
+
* browser settings are bound by HMAC-SHA-256 under a random key kept in `~/.jevitate/` (0600), never in
|
|
20
|
+
* the project: a plain hash of a short secret parameter could be guessed offline from this file. The
|
|
21
|
+
* same key signs the file, so a change by someone who can write to `.jevitate/` but cannot read the
|
|
22
|
+
* home key refuses the resume. The MAC proves the file came from this user's own runs; it does not
|
|
23
|
+
* stop that user's own processes, which can read the key, nor a rollback to an older genuine file.
|
|
24
|
+
*/
|
|
25
|
+
/** How old a reused result may be. An app changes under an old result, and nothing here can see that. */
|
|
26
|
+
export const CHECKPOINT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
/** Far above any real checkpoint. A larger file is not one. */
|
|
28
|
+
const MAX_CHECKPOINT_BYTES = 8 * 1024 * 1024;
|
|
29
|
+
const LOCK_STALE_MS = 10_000;
|
|
30
|
+
const LOCK_WAIT_MS = 5_000;
|
|
31
|
+
const LOCK_POLL_MS = 50;
|
|
32
|
+
/** A clock can step back this far before a timestamp ahead of it reads as damage. */
|
|
33
|
+
const CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
34
|
+
/** Opening what `lstat` just called a file must not block or follow a link, if it was swapped since. */
|
|
35
|
+
const READ_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);
|
|
36
|
+
const WRITE_FLAGS = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
|
|
37
|
+
/** What a refused targeted proof says when its own wording, which is not stored, is gone. */
|
|
38
|
+
export const REFUSAL_NOT_KEPT = "refused before the resume; its wording is not kept in the checkpoint";
|
|
39
|
+
/** What a retry's error says on a reused pair: errors can quote the app, so only their count is stored. */
|
|
40
|
+
export const RETRY_REASON_NOT_KEPT = "not kept in the checkpoint";
|
|
41
|
+
/** What a refused targeted proof keeps, by class: the bound is the one refusal `suggestTargetedProofPlan` reads, and the app wrote nothing in it. */
|
|
42
|
+
const REFUSAL_CLASSES = ["relaxation-bound", "other"];
|
|
43
|
+
const BOUND_REFUSAL = `relaxation bound ${MAX_TARGETED_RELAXATIONS} reached`;
|
|
44
|
+
const SITE_KEY = /^(?:step:[1-9][0-9]*|step-request:[1-9][0-9]*:[0-9]+|end-state:[0-9]+)$/;
|
|
45
|
+
const siteKey = z.string().regex(SITE_KEY);
|
|
46
|
+
const outcome = z.enum(["passed", "failed", "not-applied", "error"]);
|
|
47
|
+
const count = z.number().int().nonnegative();
|
|
48
|
+
const position = z.number().int().positive();
|
|
49
|
+
const iso = z.string().refine((s) => Number.isFinite(Date.parse(s)));
|
|
50
|
+
const digest = z.string().regex(/^[0-9a-f]{64}$/);
|
|
51
|
+
const failedSite = z.discriminatedUnion("where", [
|
|
52
|
+
z.strictObject({ where: z.literal("step"), step: position, postcondition: z.boolean() }),
|
|
53
|
+
z.strictObject({ where: z.literal("step-request"), step: position, checkIndex: count }),
|
|
54
|
+
z.strictObject({ where: z.literal("end-state"), index: count }),
|
|
55
|
+
z.strictObject({ where: z.literal("unevaluable") }),
|
|
56
|
+
]);
|
|
57
|
+
const failedSites = z.array(failedSite);
|
|
58
|
+
const waitsCut = z.array(siteKey).optional();
|
|
59
|
+
/**
|
|
60
|
+
* The replay kinds this build knows how to re-check. A kind is a record's name, not a field, so a new kind of
|
|
61
|
+
* replay in the proof adds a name here and a `runReplay` call; a file naming any other kind is refused.
|
|
62
|
+
*/
|
|
63
|
+
export const REPLAY_RECORD_KINDS = ["first", "relaxed", "targeted", "control", "targeted-control", "at-base-time", "rerun"];
|
|
64
|
+
/** Whose failure a control already answers for, or that the proof never reads as thin. Any other kind, a new one too, gets the thin check. */
|
|
65
|
+
const NOT_THIN_CHECKED = new Set(["targeted", "control", "targeted-control", "at-base-time"]);
|
|
66
|
+
/** Why a replay may have decided another, by class: no site, request or page text. */
|
|
67
|
+
const ISOLATION_NOTES = ["write-open", "independence-write-open", "control-write-open", "base-write-open", "state-crossed", "state-stuck", "independence-errored", "siblings-undecided"];
|
|
68
|
+
/** One replay a pair ran, whatever its kind: how it ended, where, when it got there, and what it left open. */
|
|
69
|
+
const replayRecord = z.strictObject({
|
|
70
|
+
kind: z.string().regex(/^[a-z][a-z-]*$/),
|
|
71
|
+
outcome,
|
|
72
|
+
failedSites,
|
|
73
|
+
reachedMs: z.array(z.number().nonnegative().nullable()),
|
|
74
|
+
endStateReachedMs: z.number().nonnegative().optional(),
|
|
75
|
+
writesInFlightAtClose: count,
|
|
76
|
+
});
|
|
77
|
+
const atBaseTime = z.strictObject({ outcome, failedSites }).optional();
|
|
78
|
+
const pairRecord = z.strictObject({
|
|
79
|
+
id: z.string().min(1),
|
|
80
|
+
kind: z.enum(["skip", "block-write", "stale-value", "swap-param", "truncate-param", "guard-param"]),
|
|
81
|
+
step: position,
|
|
82
|
+
param: z.string().optional(),
|
|
83
|
+
source: z.enum(["reviewed", "option", "generated"]).optional(),
|
|
84
|
+
outcome,
|
|
85
|
+
failedSites,
|
|
86
|
+
waitsCut,
|
|
87
|
+
relaxed: z.strictObject({ outcome, failedSites, waitsCut, atBaseTime }).optional(),
|
|
88
|
+
control: outcome.optional(),
|
|
89
|
+
/** A reading that needed a control kept none, because the verdict could no longer change. A resume runs such a pair again (`reusableOnResume`). */
|
|
90
|
+
controlSkipped: z.literal(true).optional(),
|
|
91
|
+
atBaseTime,
|
|
92
|
+
targeted: z
|
|
93
|
+
.array(z.strictObject({
|
|
94
|
+
site: siteKey,
|
|
95
|
+
relaxedSites: z.array(siteKey),
|
|
96
|
+
outcome,
|
|
97
|
+
failedSites,
|
|
98
|
+
refused: z.enum(REFUSAL_CLASSES).optional(),
|
|
99
|
+
control: outcome.optional(),
|
|
100
|
+
declared: z.literal(true).optional(),
|
|
101
|
+
}))
|
|
102
|
+
.optional(),
|
|
103
|
+
retries: count,
|
|
104
|
+
finishedAt: iso,
|
|
105
|
+
/** Every replay of this pair, each re-checked against a re-measured base. */
|
|
106
|
+
replays: z.array(replayRecord),
|
|
107
|
+
});
|
|
108
|
+
const bodySchema = z.strictObject({
|
|
109
|
+
/** 2: floors held every replay and controls match both ways, so a file of the older experiment is refused, not misread. */
|
|
110
|
+
schema: z.literal(2),
|
|
111
|
+
journeyId: z.string().min(1),
|
|
112
|
+
journeyHash: z.string().min(1),
|
|
113
|
+
planDigest: digest,
|
|
114
|
+
bindings: z.strictObject({ params: digest, hook: digest, origin: digest, browser: digest }),
|
|
115
|
+
build: z.strictObject({ version: z.string(), commit: z.string(), builtAt: z.string(), tree: z.string() }),
|
|
116
|
+
total: count,
|
|
117
|
+
completed: z.array(pairRecord),
|
|
118
|
+
phase: z.enum(["base", "independence", "pairs", "disagreements", "closing"]),
|
|
119
|
+
/**
|
|
120
|
+
* How this run's independence replay came out. Never reused: it answers whether state crosses from the
|
|
121
|
+
* base just measured, so a resume runs it again against its own base. Null until it has run.
|
|
122
|
+
*/
|
|
123
|
+
independence: z.enum(["passed", "hold-breaks-claim"]).nullable(),
|
|
124
|
+
currentPair: z.string().nullable(),
|
|
125
|
+
replay: count,
|
|
126
|
+
startedAt: iso,
|
|
127
|
+
updatedAt: iso,
|
|
128
|
+
state: z.enum(["running", "interrupted", "finished"]),
|
|
129
|
+
verdict: z.enum(["proven", "insensitive", "inconclusive"]).optional(),
|
|
130
|
+
/** It finished `inconclusive` on errored replays and nothing else, the independence replay's included, so a resume can run those again. */
|
|
131
|
+
errorGap: z.literal(true).optional(),
|
|
132
|
+
reusedPairs: count,
|
|
133
|
+
taint: z.strictObject({ restoreFailed: z.boolean(), notIsolatedBy: z.string().nullable(), isolation: z.enum(ISOLATION_NOTES).nullable() }),
|
|
134
|
+
verifier: z.strictObject({ pid: position, startToken: z.string(), host: z.string() }),
|
|
135
|
+
});
|
|
136
|
+
const fileSchema = z.strictObject({ body: bodySchema, mac: digest });
|
|
137
|
+
/**
|
|
138
|
+
* A resume that will not run. It never starts over, and a plain `--mutate` is the way to begin again.
|
|
139
|
+
* Refused before its claim, the checkpoint stays as it was. `base-failed`, `pace` and `isolation` come
|
|
140
|
+
* after the claim wrote it: its finished results stand, but it no longer says `finished`, and an
|
|
141
|
+
* `independence-errored` note is gone. That is harmless: the next resume runs the independence replay again.
|
|
142
|
+
*/
|
|
143
|
+
export class JourneyVerifyResumeRefusedError extends Error {
|
|
144
|
+
reason;
|
|
145
|
+
code = "E_JOURNEY_VERIFY_RESUME_REFUSED";
|
|
146
|
+
constructor(reason, detail) {
|
|
147
|
+
super(`journey verify --resume refused (${reason}): ${detail}. Nothing was reused and no verdict was written; run --mutate without --resume to start again`);
|
|
148
|
+
this.reason = reason;
|
|
149
|
+
this.name = "JourneyVerifyResumeRefusedError";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** A file's text, only ever to digest under the key. Follows a link, as the run will; anything but a regular file binds as nothing and cannot block. */
|
|
153
|
+
function bytesToBind(file) {
|
|
154
|
+
try {
|
|
155
|
+
return file !== undefined && statSync(file).isFile() ? readFileSync(file, "utf8") : null;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// The run itself reports a file it cannot load.
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** `<name>=<file>` with the file's bytes as a digest: the session a fixture step authenticates as is what binds, and the same path can hold another login. */
|
|
163
|
+
function fixtureIdentityBinding(spec) {
|
|
164
|
+
const bytes = bytesToBind(spec.slice(spec.indexOf("=") + 1).trim());
|
|
165
|
+
return { spec, digest: bytes === null ? null : createHash("sha256").update(bytes).digest("hex") };
|
|
166
|
+
}
|
|
167
|
+
/** What the hooks' results can depend on that this process can see: their text and limits, and the fixture files' bytes. A script a hook runs is not read. */
|
|
168
|
+
export function hookBindingOf(flags, environmentFixtures) {
|
|
169
|
+
return {
|
|
170
|
+
before: flags.before,
|
|
171
|
+
after: flags.after,
|
|
172
|
+
allowShellHooks: flags.allowShellHooks === true,
|
|
173
|
+
hookTimeoutMs: flags.hookTimeoutMs,
|
|
174
|
+
fixtures: flags.fixtures,
|
|
175
|
+
content: bytesToBind(flags.fixtures),
|
|
176
|
+
environmentFixtures,
|
|
177
|
+
environmentContent: bytesToBind(environmentFixtures),
|
|
178
|
+
fixtureIdentity: flags.fixtureIdentity?.map(fixtureIdentityBinding),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** The browser's settings and the session a replay starts from: the file's bytes too, since the same path can hold another login. */
|
|
182
|
+
export function browserBindingOf(browser, emulation, storageState, secretFields) {
|
|
183
|
+
return { browser, emulation, storageState, session: bytesToBind(storageState), secretFields };
|
|
184
|
+
}
|
|
185
|
+
/** `<project or home>/logs/verify-progress/<id>.<store>.json`. The store's digest keeps two stores' same-named Journeys apart. */
|
|
186
|
+
export function verifyCheckpointPath(dataRoot, journeysDir, id) {
|
|
187
|
+
const store = createHash("sha256").update(journeysDir).digest("hex").slice(0, 12);
|
|
188
|
+
return join(dataRoot, "logs", "verify-progress", `${encodeURIComponent(id)}.${store}.json`);
|
|
189
|
+
}
|
|
190
|
+
/** `~/.jevitate/verify-checkpoint.key`: outside every project, so no repo or artifact can carry it. */
|
|
191
|
+
export function checkpointKeyPath() {
|
|
192
|
+
return join(homeDataRoot(), "verify-checkpoint.key");
|
|
193
|
+
}
|
|
194
|
+
/** A deterministic text for a value: object keys sorted, `undefined` dropped, as JSON has it. */
|
|
195
|
+
export function canonicalJson(value) {
|
|
196
|
+
return JSON.stringify(value, (_key, v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : v));
|
|
197
|
+
}
|
|
198
|
+
function keyed(key, label, value) {
|
|
199
|
+
return createHmac("sha256", key).update(`jevitate-verify-checkpoint/1\0${label}\0`).update(canonicalJson(value) ?? "null").digest("hex");
|
|
200
|
+
}
|
|
201
|
+
/** The plan's ids and shape. It holds names and never a value, so it is a plain hash: a reader with no key can compare it. */
|
|
202
|
+
export function mutationPlanDigest(plan) {
|
|
203
|
+
const mutations = plan.mutations.map((m) => ({ kind: m.kind, step: m.step, id: m.id, param: m.param, source: m.source }));
|
|
204
|
+
const pairs = plan.pairs.map((p) => ({ site: p.site, mutation: p.mutation, declared: p.declared }));
|
|
205
|
+
return createHash("sha256").update(canonicalJson({ mutations, pairs })).digest("hex");
|
|
206
|
+
}
|
|
207
|
+
const KEY_TEXT = /^[0-9a-f]{64}$/;
|
|
208
|
+
function ownerUid() {
|
|
209
|
+
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
210
|
+
}
|
|
211
|
+
/** The key, or why it cannot be trusted. A key anyone else can read or write binds nothing. */
|
|
212
|
+
function readCheckpointKey(keyFile) {
|
|
213
|
+
let fd;
|
|
214
|
+
try {
|
|
215
|
+
if (!lstatSync(keyFile).isFile())
|
|
216
|
+
return { problem: "it is not a regular file" };
|
|
217
|
+
fd = openSync(keyFile, READ_FLAGS);
|
|
218
|
+
const opened = fstatSync(fd);
|
|
219
|
+
if (!opened.isFile() || opened.size !== 64)
|
|
220
|
+
return { problem: "it is not a key this version wrote" };
|
|
221
|
+
if (process.platform !== "win32" && (opened.mode & 0o077) !== 0)
|
|
222
|
+
return { problem: "group or others can reach it (it must be mode 0600)" };
|
|
223
|
+
const uid = ownerUid();
|
|
224
|
+
if (uid !== undefined && opened.uid !== uid)
|
|
225
|
+
return { problem: "another user owns it" };
|
|
226
|
+
const bytes = Buffer.alloc(64);
|
|
227
|
+
readSync(fd, bytes, 0, 64, 0);
|
|
228
|
+
const text = bytes.toString("utf8");
|
|
229
|
+
return KEY_TEXT.test(text) ? { key: Buffer.from(text, "hex") } : { problem: "it is not a key this version wrote" };
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
if (err.code === "ENOENT")
|
|
233
|
+
return { missing: true };
|
|
234
|
+
return { problem: `it could not be read (${err.code ?? "error"})` };
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
if (fd !== undefined)
|
|
238
|
+
closeSync(fd);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** Publishes a new key whole, by a hard link that fails if another process got there first. */
|
|
242
|
+
function createCheckpointKey(keyFile) {
|
|
243
|
+
mkdirSync(dirname(keyFile), { recursive: true, mode: 0o700 });
|
|
244
|
+
const tmp = `${keyFile}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
245
|
+
const fd = openSync(tmp, WRITE_FLAGS, 0o600);
|
|
246
|
+
try {
|
|
247
|
+
writeSync(fd, randomBytes(32).toString("hex"));
|
|
248
|
+
fsyncSync(fd);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
closeSync(fd);
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
linkSync(tmp, keyFile);
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
if (err.code !== "EEXIST")
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
unlinkSync(tmp);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Never blocks and never follows a link: a FIFO, device or symlink at the path reads as not a checkpoint. */
|
|
265
|
+
function readCheckpointFile(path) {
|
|
266
|
+
let fd;
|
|
267
|
+
let text;
|
|
268
|
+
try {
|
|
269
|
+
if (!lstatSync(path).isFile())
|
|
270
|
+
return { kind: "not-regular" };
|
|
271
|
+
fd = openSync(path, READ_FLAGS);
|
|
272
|
+
const opened = fstatSync(fd);
|
|
273
|
+
if (!opened.isFile())
|
|
274
|
+
return { kind: "not-regular" };
|
|
275
|
+
if (opened.size > MAX_CHECKPOINT_BYTES)
|
|
276
|
+
return { kind: "corrupt", detail: `it is larger than ${MAX_CHECKPOINT_BYTES} bytes` };
|
|
277
|
+
const bytes = Buffer.alloc(opened.size);
|
|
278
|
+
let read = 0;
|
|
279
|
+
while (read < opened.size) {
|
|
280
|
+
const got = readSync(fd, bytes, read, opened.size - read, read);
|
|
281
|
+
if (got === 0)
|
|
282
|
+
break;
|
|
283
|
+
read += got;
|
|
284
|
+
}
|
|
285
|
+
text = bytes.toString("utf8", 0, read);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
const code = err.code;
|
|
289
|
+
if (code === "ENOENT")
|
|
290
|
+
return { kind: "none" };
|
|
291
|
+
if (code === "ELOOP" || code === "ENXIO")
|
|
292
|
+
return { kind: "not-regular" };
|
|
293
|
+
return { kind: "corrupt", detail: `it could not be read (${code ?? "error"})` };
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
if (fd !== undefined)
|
|
297
|
+
closeSync(fd);
|
|
298
|
+
}
|
|
299
|
+
let raw;
|
|
300
|
+
try {
|
|
301
|
+
raw = JSON.parse(text);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return { kind: "corrupt", detail: "it is not whole JSON (truncated or damaged)" };
|
|
305
|
+
}
|
|
306
|
+
const parsed = fileSchema.safeParse(raw);
|
|
307
|
+
if (!parsed.success)
|
|
308
|
+
return { kind: "corrupt", detail: "it does not have the checkpoint's shape" };
|
|
309
|
+
return { kind: "read", body: parsed.data.body, mac: parsed.data.mac };
|
|
310
|
+
}
|
|
311
|
+
function macMatches(key, body, mac) {
|
|
312
|
+
return timingSafeEqual(Buffer.from(keyed(key, "file", body), "hex"), Buffer.from(mac, "hex"));
|
|
313
|
+
}
|
|
314
|
+
/** tmp, fsync, rename, then the directory: a reader sees the old file or the new one, whole. Refuses to replace what this code did not write. */
|
|
315
|
+
function writeCheckpointFile(path, key, body) {
|
|
316
|
+
const dir = dirname(path);
|
|
317
|
+
mkdirSync(dir, { recursive: true });
|
|
318
|
+
if (!lstatSync(dir).isDirectory())
|
|
319
|
+
throw new Error("the progress directory is a link or a file");
|
|
320
|
+
try {
|
|
321
|
+
if (!lstatSync(path).isFile())
|
|
322
|
+
throw new Error("something other than a regular file is at the checkpoint's path");
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
if (err.code !== "ENOENT")
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
329
|
+
const fd = openSync(tmp, WRITE_FLAGS, 0o600);
|
|
330
|
+
try {
|
|
331
|
+
try {
|
|
332
|
+
writeSync(fd, JSON.stringify({ body, mac: keyed(key, "file", body) }));
|
|
333
|
+
fsyncSync(fd);
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
closeSync(fd);
|
|
337
|
+
}
|
|
338
|
+
renameSync(tmp, path);
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
try {
|
|
342
|
+
unlinkSync(tmp);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// The write's own error is the one to report; a leftover tmp is never read.
|
|
346
|
+
}
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
const dirFd = openSync(dir, constants.O_RDONLY);
|
|
350
|
+
try {
|
|
351
|
+
fsyncSync(dirFd);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
// Some filesystems refuse fsync on a directory; the rename has still happened.
|
|
355
|
+
if (!["EINVAL", "EPERM", "EISDIR", "ENOTSUP"].includes(err.code ?? ""))
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
closeSync(dirFd);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** A short critical section so two starters cannot both claim one checkpoint. A dead holder's lock ages out. */
|
|
363
|
+
async function withCheckpointLock(path, body) {
|
|
364
|
+
const lock = `${path}.lock`;
|
|
365
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
366
|
+
const giveUpAt = clock.monotonicMs() + LOCK_WAIT_MS;
|
|
367
|
+
for (;;) {
|
|
368
|
+
try {
|
|
369
|
+
mkdirSync(lock);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
if (err.code !== "EEXIST")
|
|
374
|
+
throw err;
|
|
375
|
+
try {
|
|
376
|
+
if (clock.now() - lstatSync(lock).mtimeMs > LOCK_STALE_MS)
|
|
377
|
+
rmdirSync(lock);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// Released while it was looked at, or not a directory: the next mkdir says which.
|
|
381
|
+
}
|
|
382
|
+
if (clock.monotonicMs() >= giveUpAt)
|
|
383
|
+
return "busy";
|
|
384
|
+
await clock.sleep(LOCK_POLL_MS);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
return body();
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
try {
|
|
392
|
+
rmdirSync(lock);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// Already reclaimed as stale: the critical section is over either way.
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** A zombie answers `kill(pid, 0)`, but it runs nothing. */
|
|
400
|
+
function isZombie(pid) {
|
|
401
|
+
if (process.platform !== "linux")
|
|
402
|
+
return false;
|
|
403
|
+
try {
|
|
404
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
405
|
+
return stat.slice(stat.lastIndexOf(")") + 2, stat.lastIndexOf(")") + 3) === "Z";
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/** This process, by pid and the kernel's start time for it: a reused pid has another start time. */
|
|
412
|
+
export function currentVerifierIdentity() {
|
|
413
|
+
return { pid: process.pid, startToken: systemProcessTable().startOf(process.pid) ?? "0", host: hostname() };
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* live: the recorded process runs, with the recorded start token, and its proof has not ended. exited:
|
|
417
|
+
* the proof ended, or the process is gone. stale: a process runs under that pid with another start token
|
|
418
|
+
* (the pid was reused), or the record is from another host, where this one cannot check it.
|
|
419
|
+
* Where the platform gives no start time (token "0"), a running pid counts as live: age ends that.
|
|
420
|
+
*/
|
|
421
|
+
export function verifierStateOf(body, startOf = (pid) => systemProcessTable().startOf(pid)) {
|
|
422
|
+
if (body.state !== "running")
|
|
423
|
+
return "exited";
|
|
424
|
+
if (body.verifier.host !== hostname())
|
|
425
|
+
return "stale";
|
|
426
|
+
const token = startOf(body.verifier.pid);
|
|
427
|
+
if (token === undefined || isZombie(body.verifier.pid))
|
|
428
|
+
return "exited";
|
|
429
|
+
return token === body.verifier.startToken ? "live" : "stale";
|
|
430
|
+
}
|
|
431
|
+
/** True when a live verifier is still writing this receipt: `logs prune` leaves it, however old. */
|
|
432
|
+
export function isLiveVerifyReceipt(path) {
|
|
433
|
+
const found = readCheckpointFile(path);
|
|
434
|
+
return found.kind === "read" && verifierStateOf(found.body) === "live";
|
|
435
|
+
}
|
|
436
|
+
/** The oldest time a reused result was measured at. */
|
|
437
|
+
function oldestResultMs(body) {
|
|
438
|
+
return Math.min(Date.parse(body.updatedAt), ...body.completed.map((p) => Date.parse(p.finishedAt)));
|
|
439
|
+
}
|
|
440
|
+
function ageRefusal(body) {
|
|
441
|
+
const now = clock.now();
|
|
442
|
+
if (Date.parse(body.updatedAt) > now + CLOCK_SKEW_MS)
|
|
443
|
+
return "its last update is in the future";
|
|
444
|
+
const age = now - oldestResultMs(body);
|
|
445
|
+
return age > CHECKPOINT_MAX_AGE_MS ? `its oldest result is ${Math.round(age / 3_600_000)}h old, over the ${CHECKPOINT_MAX_AGE_MS / 3_600_000}h limit` : undefined;
|
|
446
|
+
}
|
|
447
|
+
function unknownRecordKind(body) {
|
|
448
|
+
return body.completed.flatMap((p) => p.replays).find((r) => !REPLAY_RECORD_KINDS.includes(r.kind))?.kind;
|
|
449
|
+
}
|
|
450
|
+
/** Every reused result against the plan and the Journey: a result the plan could not have produced refuses the resume. */
|
|
451
|
+
function pairProblems(body, plan, journey, sites) {
|
|
452
|
+
const stepCount = journey.recording.pages.reduce((n, p) => n + p.steps.length, 0);
|
|
453
|
+
const keys = new Set(sites.map(assertionSiteKey));
|
|
454
|
+
const siteProblem = (f) => ("step" in f && f.step > stepCount) || (f.where === "end-state" && !keys.has(`end-state:${f.index}`));
|
|
455
|
+
if (body.total !== plan.mutations.length)
|
|
456
|
+
return `it counts ${body.total} pairs and the plan ${plan.mutations.length}`;
|
|
457
|
+
if (body.completed.length > plan.mutations.length)
|
|
458
|
+
return "it holds more results than the plan has pairs";
|
|
459
|
+
if (body.taint.notIsolatedBy !== null && !plan.mutations.some((m) => m.id === body.taint.notIsolatedBy))
|
|
460
|
+
return "its isolation note names no planned pair";
|
|
461
|
+
for (const [i, pair] of body.completed.entries()) {
|
|
462
|
+
const m = plan.mutations[i];
|
|
463
|
+
if (pair.id !== m.id || pair.kind !== m.kind || pair.step !== m.step || pair.param !== m.param || pair.source !== m.source)
|
|
464
|
+
return `result ${i + 1} is not planned pair ${m.id}`;
|
|
465
|
+
const runs = [pair, ...(pair.relaxed === undefined ? [] : [pair.relaxed]), ...(pair.targeted ?? [])];
|
|
466
|
+
for (const run of runs) {
|
|
467
|
+
if (run.outcome !== "failed" && run.failedSites.length > 0)
|
|
468
|
+
return `${m.id} has failed sites on a replay that did not fail`;
|
|
469
|
+
if (run.failedSites.some(siteProblem))
|
|
470
|
+
return `${m.id} names a site this Journey does not have`;
|
|
471
|
+
}
|
|
472
|
+
if (pair.replays.some((r) => r.failedSites.some(siteProblem)))
|
|
473
|
+
return `${m.id} names a site this Journey does not have`;
|
|
474
|
+
if (pair.replays.some((r) => r.outcome !== "failed" && r.failedSites.length > 0))
|
|
475
|
+
return `${m.id} has failed sites on a replay that did not fail`;
|
|
476
|
+
if ((pair.relaxed !== undefined) !== needsRelaxedReplay(plan, m, pair))
|
|
477
|
+
return `${m.id} does not hold the replays its first result calls for`;
|
|
478
|
+
for (const t of pair.targeted ?? []) {
|
|
479
|
+
if (!keys.has(t.site))
|
|
480
|
+
return `${m.id} has a targeted proof of a site this Journey does not have`;
|
|
481
|
+
if (!plan.pairs.some((p) => p.mutation === m.id && p.site === t.site))
|
|
482
|
+
return `${m.id} has a targeted proof of a site it is not paired with`;
|
|
483
|
+
}
|
|
484
|
+
// Every result the verdict reads must stand on a replay record that says the same.
|
|
485
|
+
const ofKind = (kind) => pair.replays.filter((r) => r.kind === kind);
|
|
486
|
+
const sameRun = (r, run) => r.outcome === run.outcome && canonicalJson(r.failedSites) === canonicalJson(run.failedSites);
|
|
487
|
+
if (ofKind("first").length !== 1 || !sameRun(ofKind("first")[0], pair))
|
|
488
|
+
return `${m.id} has no replay record for its first result`;
|
|
489
|
+
if (pair.relaxed === undefined ? ofKind("relaxed").length !== 0 : ofKind("relaxed").length !== 1 || !sameRun(ofKind("relaxed")[0], pair.relaxed))
|
|
490
|
+
return `${m.id} has no replay record for its relaxed result`;
|
|
491
|
+
if (pair.control === undefined ? ofKind("control").length !== 0 : ofKind("control").length !== 1 || ofKind("control")[0].outcome !== pair.control)
|
|
492
|
+
return `${m.id} has no replay record for its control`;
|
|
493
|
+
for (const t of pair.targeted ?? []) {
|
|
494
|
+
// A proof refused on the mutated replay's own control repeats that control's class, and ran none of its own.
|
|
495
|
+
if (t.control !== undefined && t.control !== pair.control && !ofKind("targeted-control").some((r) => r.outcome === t.control))
|
|
496
|
+
return `${m.id} has no replay record for a targeted control`;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* The one isolation note a resume can answer: it says the independence replay gave no reading, not that
|
|
503
|
+
* state crossed. A resume never reuses that replay's answer and runs it against the base it re-measures,
|
|
504
|
+
* so dropping the note leaves that fresh run to decide; if it errors too, the note comes back.
|
|
505
|
+
*/
|
|
506
|
+
export const isRepairableIsolation = (taint) => taint.isolation === "independence-errored" && !taint.restoreFailed;
|
|
507
|
+
/**
|
|
508
|
+
* What a resume may reuse. An errored pair proved nothing. A pair that skipped its control stands on
|
|
509
|
+
* "the verdict is already decided", and an errored pair is one reason for that, which the resume then
|
|
510
|
+
* takes away: reused, its unvouched reading could turn the proof `proven`. So it runs again, whole,
|
|
511
|
+
* deciding afresh whether its control is needed. The control alone cannot be run on the old result: it has to reach
|
|
512
|
+
* each site no sooner than the replay it answers for did (`matchTo`), and a checkpoint keeps no arrivals.
|
|
513
|
+
*/
|
|
514
|
+
const reusableOnResume = (pair) => !mutationErrored(reusedProofFields(pair)) && pair.controlSkipped !== true;
|
|
515
|
+
/** A proof that ended on errored replays has a gap a resume can fill. Any other finished proof has said all it can. */
|
|
516
|
+
const finishedForGood = (body) => body.state === "finished" && !(body.verdict === "inconclusive" && body.errorGap === true);
|
|
517
|
+
/** Why a finished proof takes no resume. One a replay may have decided has nothing a resume could stand on, and it says what will help. */
|
|
518
|
+
function finishedDetail(body) {
|
|
519
|
+
const ended = `this proof already finished (${body.verdict ?? "no verdict"})`;
|
|
520
|
+
const tainted = body.taint.restoreFailed || body.taint.notIsolatedBy !== null || body.taint.isolation !== null;
|
|
521
|
+
return tainted ? `${ended}, and a replay may have decided another, so no result of it can be reused; fix that cause, then rerun with plain --mutate` : `${ended}; its receipt stands`;
|
|
522
|
+
}
|
|
523
|
+
/** One proof's writer. A failed write never ends a proof: the next one writes everything again. */
|
|
524
|
+
export class ProofCheckpointSession {
|
|
525
|
+
path;
|
|
526
|
+
key;
|
|
527
|
+
onNotice;
|
|
528
|
+
#body;
|
|
529
|
+
#failed = false;
|
|
530
|
+
constructor(path, key, body, onNotice) {
|
|
531
|
+
this.path = path;
|
|
532
|
+
this.key = key;
|
|
533
|
+
this.onNotice = onNotice;
|
|
534
|
+
this.#body = body;
|
|
535
|
+
}
|
|
536
|
+
get body() {
|
|
537
|
+
return this.#body;
|
|
538
|
+
}
|
|
539
|
+
#write(change) {
|
|
540
|
+
this.#body = { ...this.#body, ...change, updatedAt: clock.nowIso() };
|
|
541
|
+
try {
|
|
542
|
+
writeCheckpointFile(this.path, this.key, this.#body);
|
|
543
|
+
this.#failed = false;
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
if (!this.#failed)
|
|
548
|
+
this.onNotice(`journey verify: progress could not be saved (${err.code ?? "error"}); the proof goes on, and a resume may repeat work`);
|
|
549
|
+
this.#failed = true;
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
replayStarted(replay) {
|
|
554
|
+
this.#write({ replay });
|
|
555
|
+
}
|
|
556
|
+
independence(independence) {
|
|
557
|
+
this.#write({ independence });
|
|
558
|
+
}
|
|
559
|
+
phase(phase) {
|
|
560
|
+
this.#write({ phase, currentPair: null });
|
|
561
|
+
}
|
|
562
|
+
startPair(id) {
|
|
563
|
+
this.#write({ phase: "pairs", currentPair: id });
|
|
564
|
+
}
|
|
565
|
+
/** A pair run again after it errored takes its own place, so `completed` stays in the plan's order. */
|
|
566
|
+
finishPair(pair) {
|
|
567
|
+
const known = this.#body.completed.some((p) => p.id === pair.id);
|
|
568
|
+
this.#write({ completed: known ? this.#body.completed.map((p) => (p.id === pair.id ? pair : p)) : [...this.#body.completed, pair], currentPair: null });
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Written the moment it happens: a taint that dies with the process could turn a resumed proof `proven`.
|
|
572
|
+
* So if it cannot be written (a full disk), the older file without it must not stay resumable. A later
|
|
573
|
+
* write that succeeds carries the taint, since each write holds the whole body.
|
|
574
|
+
*/
|
|
575
|
+
taint(change) {
|
|
576
|
+
if (this.#write({ taint: { ...this.#body.taint, ...change } }))
|
|
577
|
+
return;
|
|
578
|
+
try {
|
|
579
|
+
unlinkSync(this.path);
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
// Nothing more can be done here: the proof itself still carries the taint to its verdict.
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
finish(verdict, errorGap) {
|
|
586
|
+
this.#write({ state: "finished", verdict, currentPair: null, ...(errorGap ? { errorGap: true } : {}) });
|
|
587
|
+
}
|
|
588
|
+
/** The proof ended with no verdict, in a process that may go on (an MCP server), so its pid no longer says anything. */
|
|
589
|
+
interrupted() {
|
|
590
|
+
if (this.#body.state === "running")
|
|
591
|
+
this.#write({ state: "interrupted" });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const refuse = (reason, detail) => {
|
|
595
|
+
throw new JourneyVerifyResumeRefusedError(reason, detail);
|
|
596
|
+
};
|
|
597
|
+
/**
|
|
598
|
+
* Claims the checkpoint for this run, before any browser. A resume returns the results it may reuse or
|
|
599
|
+
* refuses by name. A fresh run replaces an ended checkpoint and leaves a live verifier's alone, going
|
|
600
|
+
* on without progress of its own, since a proof that ran before this file existed must still run.
|
|
601
|
+
*/
|
|
602
|
+
export async function openProofCheckpoint(req, proof) {
|
|
603
|
+
const notice = req.onNotice ?? (() => undefined);
|
|
604
|
+
const journeyId = proof.journey.metadata.id;
|
|
605
|
+
let keyRead = readCheckpointKey(req.keyFile);
|
|
606
|
+
if ("missing" in keyRead && !req.resume) {
|
|
607
|
+
try {
|
|
608
|
+
createCheckpointKey(req.keyFile);
|
|
609
|
+
keyRead = readCheckpointKey(req.keyFile);
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
keyRead = { problem: `it could not be created (${err.code ?? "error"})` };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (!("key" in keyRead)) {
|
|
616
|
+
const why = "missing" in keyRead ? "it does not exist" : keyRead.problem;
|
|
617
|
+
if (req.resume)
|
|
618
|
+
return refuse("key-unavailable", `the checkpoint key ${req.keyFile} cannot be used: ${why}`);
|
|
619
|
+
notice(`journey verify: no progress is kept for this run, because the checkpoint key ${req.keyFile} cannot be used: ${why}`);
|
|
620
|
+
return {};
|
|
621
|
+
}
|
|
622
|
+
const { key } = keyRead;
|
|
623
|
+
const bindings = {
|
|
624
|
+
params: keyed(key, "params", proof.params),
|
|
625
|
+
hook: keyed(key, "hook", req.config.hook),
|
|
626
|
+
origin: keyed(key, "origin", req.config.origin),
|
|
627
|
+
browser: keyed(key, "browser", req.config.browser),
|
|
628
|
+
};
|
|
629
|
+
const planDigest = mutationPlanDigest(proof.plan);
|
|
630
|
+
const now = clock.nowIso();
|
|
631
|
+
const fresh = {
|
|
632
|
+
schema: 2,
|
|
633
|
+
journeyId,
|
|
634
|
+
journeyHash: proof.journeyHash,
|
|
635
|
+
planDigest,
|
|
636
|
+
bindings,
|
|
637
|
+
build: req.build,
|
|
638
|
+
total: proof.plan.mutations.length,
|
|
639
|
+
completed: [],
|
|
640
|
+
phase: "base",
|
|
641
|
+
currentPair: null,
|
|
642
|
+
replay: 0,
|
|
643
|
+
startedAt: now,
|
|
644
|
+
updatedAt: now,
|
|
645
|
+
state: "running",
|
|
646
|
+
reusedPairs: 0,
|
|
647
|
+
independence: null,
|
|
648
|
+
taint: { restoreFailed: false, notIsolatedBy: null, isolation: null },
|
|
649
|
+
verifier: currentVerifierIdentity(),
|
|
650
|
+
};
|
|
651
|
+
const claimFresh = () => {
|
|
652
|
+
const found = readCheckpointFile(req.path);
|
|
653
|
+
if (found.kind === "not-regular") {
|
|
654
|
+
notice(`journey verify: no progress is kept for this run: something other than a regular file is at ${req.path}`);
|
|
655
|
+
return {};
|
|
656
|
+
}
|
|
657
|
+
if (found.kind === "read" && macMatches(key, found.body, found.mac)) {
|
|
658
|
+
const verifier = verifierStateOf(found.body);
|
|
659
|
+
if (verifier === "live" || (verifier === "stale" && found.body.verifier.host !== hostname())) {
|
|
660
|
+
notice(`journey verify: no progress is kept for this run: verifier ${found.body.verifier.pid} owns ${req.path}`);
|
|
661
|
+
return {};
|
|
662
|
+
}
|
|
663
|
+
if (found.body.state !== "finished" && found.body.completed.length > 0)
|
|
664
|
+
notice(`journey verify: starting again replaces a checkpoint with ${found.body.completed.length} finished pairs; --resume would have reused them`);
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
writeCheckpointFile(req.path, key, fresh);
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
notice(`journey verify: no progress is kept for this run: ${err.code ?? "the checkpoint could not be written"}`);
|
|
671
|
+
return {};
|
|
672
|
+
}
|
|
673
|
+
return { session: new ProofCheckpointSession(req.path, key, fresh, notice) };
|
|
674
|
+
};
|
|
675
|
+
const claimResume = () => {
|
|
676
|
+
const found = readCheckpointFile(req.path);
|
|
677
|
+
if (found.kind === "none")
|
|
678
|
+
return refuse("no-checkpoint", `there is no checkpoint for journey '${journeyId}' at ${req.path}`);
|
|
679
|
+
if (found.kind === "not-regular")
|
|
680
|
+
return refuse("not-regular-file", `something other than a regular file is at ${req.path}`);
|
|
681
|
+
if (found.kind === "corrupt")
|
|
682
|
+
return refuse("corrupt", `the checkpoint at ${req.path} cannot be used: ${found.detail}`);
|
|
683
|
+
const { body } = found;
|
|
684
|
+
if (!macMatches(key, body, found.mac))
|
|
685
|
+
return refuse("integrity", `the checkpoint at ${req.path} does not carry this user's key: it was changed outside jevitate, or the key was replaced`);
|
|
686
|
+
if (body.journeyId !== journeyId)
|
|
687
|
+
return refuse("corrupt", `the checkpoint at ${req.path} belongs to another Journey`);
|
|
688
|
+
const verifier = verifierStateOf(body);
|
|
689
|
+
if (verifier === "live")
|
|
690
|
+
return refuse("live-verifier", `verifier ${body.verifier.pid} is still running this proof; read it with --status`);
|
|
691
|
+
if (body.state === "running" && body.verifier.host !== hostname())
|
|
692
|
+
return refuse("other-host", `verifier ${body.verifier.pid} ran on host ${body.verifier.host}, where this host cannot check it`);
|
|
693
|
+
if (finishedForGood(body))
|
|
694
|
+
return refuse("finished", finishedDetail(body));
|
|
695
|
+
if (body.journeyHash !== proof.journeyHash)
|
|
696
|
+
return refuse("journey-hash", "the Journey changed since the checkpoint");
|
|
697
|
+
if (body.planDigest !== planDigest)
|
|
698
|
+
return refuse("mutation-plan", "the mutation plan changed since the checkpoint");
|
|
699
|
+
if (body.bindings.params !== bindings.params)
|
|
700
|
+
return refuse("params", "the parameters differ from the checkpoint's");
|
|
701
|
+
if (body.bindings.hook !== bindings.hook)
|
|
702
|
+
return refuse("hook", "the hooks or fixtures differ from the checkpoint's");
|
|
703
|
+
if (body.bindings.origin !== bindings.origin)
|
|
704
|
+
return refuse("origin", "the environment, base URL, allowed origins or read requests differ from the checkpoint's");
|
|
705
|
+
if (body.bindings.browser !== bindings.browser)
|
|
706
|
+
return refuse("browser", "the browser, emulation or session settings differ from the checkpoint's");
|
|
707
|
+
if (req.build.commit === "unknown" || req.build.builtAt === "unknown")
|
|
708
|
+
return refuse("cli-build", "this build cannot say which build it is, so it cannot be shown to be the checkpoint's");
|
|
709
|
+
if (canonicalJson(body.build) !== canonicalJson(req.build))
|
|
710
|
+
return refuse("cli-build", `the checkpoint was written by ${body.build.version} (${body.build.commit}, built ${body.build.builtAt}) and this is ${req.build.version} (${req.build.commit}, built ${req.build.builtAt})`);
|
|
711
|
+
const unknown = unknownRecordKind(body);
|
|
712
|
+
if (unknown !== undefined)
|
|
713
|
+
return refuse("unknown-record", `the checkpoint holds a replay record of kind '${unknown}', which this build cannot re-check`);
|
|
714
|
+
const old = ageRefusal(body);
|
|
715
|
+
if (old !== undefined)
|
|
716
|
+
return refuse("too-old", old);
|
|
717
|
+
const invalid = pairProblems(body, proof.plan, proof.journey, proof.sites);
|
|
718
|
+
if (invalid !== undefined)
|
|
719
|
+
return refuse("corrupt", `the checkpoint at ${req.path} cannot be used: ${invalid}`);
|
|
720
|
+
// A pair run again stays on file until its new result takes its place, and is never reused.
|
|
721
|
+
const reused = body.completed.filter(reusableOnResume);
|
|
722
|
+
const { verdict: _verdict, errorGap: _errorGap, ...unfinished } = body;
|
|
723
|
+
const taint = body.taint.isolation === "independence-errored" ? { ...body.taint, isolation: null, notIsolatedBy: null } : body.taint;
|
|
724
|
+
const claimed = { ...unfinished, taint, state: "running", phase: "base", independence: null, currentPair: null, replay: 0, reusedPairs: reused.length, updatedAt: clock.nowIso(), verifier: currentVerifierIdentity() };
|
|
725
|
+
try {
|
|
726
|
+
// A resume that cannot record its claim cannot keep a second verifier out.
|
|
727
|
+
writeCheckpointFile(req.path, key, claimed);
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
return refuse("not-writable", `the claim could not be written to ${req.path} (${err.code ?? "error"})`);
|
|
731
|
+
}
|
|
732
|
+
const again = body.completed.length - reused.length;
|
|
733
|
+
notice(`journey verify: resuming with ${reused.length} of ${body.total} pairs finished${again === 0 ? "" : ` and ${again} errored or short of a control, which run again`}; the base replay runs first and must pass before any is reused`);
|
|
734
|
+
const session = new ProofCheckpointSession(req.path, key, claimed, notice);
|
|
735
|
+
return { session, resume: { pairs: new Map(reused.map((p) => [p.id, p])), restoreFailed: taint.restoreFailed, notIsolatedBy: taint.notIsolatedBy, isolation: taint.isolation } };
|
|
736
|
+
};
|
|
737
|
+
const opened = await withCheckpointLock(req.path, req.resume ? claimResume : claimFresh).catch((err) => {
|
|
738
|
+
if (err instanceof JourneyVerifyResumeRefusedError)
|
|
739
|
+
throw err;
|
|
740
|
+
if (req.resume)
|
|
741
|
+
return refuse("not-writable", `the checkpoint could not be claimed (${err.code ?? "error"})`);
|
|
742
|
+
notice(`journey verify: no progress is kept for this run: ${err.code ?? "the checkpoint could not be claimed"}`);
|
|
743
|
+
return {};
|
|
744
|
+
});
|
|
745
|
+
if (opened !== "busy")
|
|
746
|
+
return opened;
|
|
747
|
+
if (req.resume)
|
|
748
|
+
return refuse("live-verifier", "another process is claiming this checkpoint right now");
|
|
749
|
+
notice("journey verify: no progress is kept for this run: another process is claiming its checkpoint");
|
|
750
|
+
return {};
|
|
751
|
+
}
|
|
752
|
+
/** The storable form of a refusal: its class, and none of its wording. */
|
|
753
|
+
function storableRefusal(refused) {
|
|
754
|
+
return refused === BOUND_REFUSAL ? "relaxation-bound" : "other";
|
|
755
|
+
}
|
|
756
|
+
const structuredSites = (sites) => sites.map((f) => f.where === "step" ? { where: "step", step: f.step, postcondition: f.postcondition } : f.where === "step-request" ? { where: "step-request", step: f.step, checkIndex: f.checkIndex } : f.where === "end-state" ? { where: "end-state", index: f.index } : { where: "unevaluable" });
|
|
757
|
+
const storedAtBaseTime = (run) => run === undefined ? undefined : { outcome: run.outcome, failedSites: structuredSites(run.failedSites) };
|
|
758
|
+
const optional = (key, value) => (value === undefined ? {} : { [key]: value });
|
|
759
|
+
export function checkpointPairOf(pair, replays) {
|
|
760
|
+
return {
|
|
761
|
+
id: pair.id,
|
|
762
|
+
kind: pair.kind,
|
|
763
|
+
step: pair.step,
|
|
764
|
+
...optional("param", pair.param),
|
|
765
|
+
...optional("source", pair.source),
|
|
766
|
+
outcome: pair.outcome,
|
|
767
|
+
failedSites: structuredSites(pair.failedSites),
|
|
768
|
+
...optional("waitsCut", pair.waitsCut === undefined ? undefined : [...pair.waitsCut]),
|
|
769
|
+
...optional("relaxed", pair.relaxed === undefined ? undefined : { outcome: pair.relaxed.outcome, failedSites: structuredSites(pair.relaxed.failedSites), ...optional("waitsCut", pair.relaxed.waitsCut === undefined ? undefined : [...pair.relaxed.waitsCut]), ...optional("atBaseTime", storedAtBaseTime(pair.relaxed.atBaseTime)) }),
|
|
770
|
+
...optional("control", pair.control),
|
|
771
|
+
...optional("controlSkipped", pair.controlSkipped),
|
|
772
|
+
...optional("atBaseTime", storedAtBaseTime(pair.atBaseTime)),
|
|
773
|
+
...optional("targeted", pair.targeted?.map((t) => ({
|
|
774
|
+
site: t.site,
|
|
775
|
+
relaxedSites: [...t.relaxedSites],
|
|
776
|
+
outcome: t.outcome,
|
|
777
|
+
failedSites: structuredSites(t.failedSites),
|
|
778
|
+
...optional("refused", t.refused === undefined ? undefined : storableRefusal(t.refused)),
|
|
779
|
+
...optional("control", t.control),
|
|
780
|
+
...optional("declared", t.declared),
|
|
781
|
+
}))),
|
|
782
|
+
retries: pair.retries?.length ?? 0,
|
|
783
|
+
finishedAt: clock.nowIso(),
|
|
784
|
+
replays: [...replays],
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/** One replay in the file's form, by kind. Named fields only, so a `reason` on the run cannot reach the file. */
|
|
788
|
+
export function checkpointReplayOf(kind, run, writesInFlightAtClose) {
|
|
789
|
+
return { kind, outcome: run.outcome, failedSites: structuredSites(run.failedSites), reachedMs: Array.from(run.reachedMs ?? [], (ms) => ms ?? null), ...optional("endStateReachedMs", run.endStateReachedMs), writesInFlightAtClose };
|
|
790
|
+
}
|
|
791
|
+
/** The result `classifyMutation` and `mutationProofVerdict` read, as the uninterrupted proof gave them. */
|
|
792
|
+
export function reusedProofFields(pair) {
|
|
793
|
+
return {
|
|
794
|
+
outcome: pair.outcome,
|
|
795
|
+
failedSites: pair.failedSites,
|
|
796
|
+
...optional("waitsCut", pair.waitsCut),
|
|
797
|
+
...optional("relaxed", pair.relaxed),
|
|
798
|
+
...optional("control", pair.control),
|
|
799
|
+
...optional("atBaseTime", pair.atBaseTime),
|
|
800
|
+
...optional("targeted", pair.targeted?.map(({ refused, ...t }) => ({ ...t, ...optional("refused", refused === undefined ? undefined : refused === "relaxation-bound" ? BOUND_REFUSAL : REFUSAL_NOT_KEPT) }))),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
const undefinedHoles = (reached) => reached.map((ms) => ms ?? undefined);
|
|
804
|
+
/**
|
|
805
|
+
* The reused results against a base measured just now. A reused replay was paced to the base of its own
|
|
806
|
+
* run. Under the rule (`evidenceRefusal`) its failure is evidence only if it got there no sooner than an
|
|
807
|
+
* unmutated replay passes that site, and the replay that says so now is the re-measured one. So each
|
|
808
|
+
* reused failure is read again against the new pace, by the same functions the proof uses: reached
|
|
809
|
+
* sooner than the new base, or newly thin with no control that answered for it, refuses the whole resume.
|
|
810
|
+
* A failed replay already cut needs nothing: it counts for nothing either way. A faster base only
|
|
811
|
+
* leaves a reused replay more room. Every record goes through the same loop whatever its kind, controls
|
|
812
|
+
* included. Writes left open are compared as the proof compares them.
|
|
813
|
+
*/
|
|
814
|
+
export function resumeRefusalAgainstBase(state, plan, sites, pace, baseWritesInFlightAtClose) {
|
|
815
|
+
const siteByKey = new Map(sites.map((s) => [assertionSiteKey(s), s]));
|
|
816
|
+
for (const m of plan.mutations) {
|
|
817
|
+
const pair = state.pairs.get(m.id);
|
|
818
|
+
if (pair === undefined)
|
|
819
|
+
continue;
|
|
820
|
+
for (const r of pair.replays) {
|
|
821
|
+
if (r.writesInFlightAtClose > baseWritesInFlightAtClose) {
|
|
822
|
+
return new JourneyVerifyResumeRefusedError("isolation", `a ${r.kind} replay of ${m.id} closed with ${r.writesInFlightAtClose} writes open and the re-measured base with ${baseWritesInFlightAtClose}, so it may have decided a later replay`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const result = reusedProofFields(pair);
|
|
826
|
+
// Only a site read sensitive can turn into a false `proven`: a pair with none cannot change by being cut.
|
|
827
|
+
const sensitive = plan.pairs.filter((p) => {
|
|
828
|
+
const site = siteByKey.get(p.site);
|
|
829
|
+
return p.mutation === m.id && site !== undefined && classifyMutation(site, m, result) === "sensitive";
|
|
830
|
+
});
|
|
831
|
+
if (sensitive.length === 0)
|
|
832
|
+
continue;
|
|
833
|
+
const cutFor = (r) => (r.kind === "first" ? pair.waitsCut : r.kind === "relaxed" ? pair.relaxed?.waitsCut : undefined);
|
|
834
|
+
for (const r of pair.replays) {
|
|
835
|
+
// A replay at the base's time only ever says a claim held without the mutation, and a failure there is no reading.
|
|
836
|
+
if (r.outcome !== "failed" || r.kind === "at-base-time" || (cutFor(r)?.length ?? 0) > 0)
|
|
837
|
+
continue;
|
|
838
|
+
const reached = undefinedHoles(r.reachedMs);
|
|
839
|
+
const early = sitesReachedEarly(r.failedSites, m, pace, reached, r.endStateReachedMs);
|
|
840
|
+
if (early.length > 0)
|
|
841
|
+
return new JourneyVerifyResumeRefusedError("pace", `a ${r.kind} replay of ${m.id} failed at ${early.join(", ")} sooner than the re-measured base replay passed it, so the reused failure is not evidence`);
|
|
842
|
+
if (NOT_THIN_CHECKED.has(r.kind) || pair.control !== undefined)
|
|
843
|
+
continue;
|
|
844
|
+
const thin = thinSites(r.failedSites, m, pace, reached, r.endStateReachedMs);
|
|
845
|
+
if (sensitive.some((p) => thin.includes(p.site)))
|
|
846
|
+
return new JourneyVerifyResumeRefusedError("pace", `a ${r.kind} replay of ${m.id} reached ${thin.join(", ")} with little to spare over the re-measured base replay, and no control replay answers for it`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return undefined;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* `journey verify <id> --status`: reads the file and asks the OS about one pid. No browser, no hook, no
|
|
853
|
+
* Journey and nothing of the app's. A key is read when there is one and never created. `resume` answers
|
|
854
|
+
* what can be answered without the run's flags, so `possible: true` still leaves the config checks.
|
|
855
|
+
*/
|
|
856
|
+
export function readVerifyProgress(path, keyFile, journeyId) {
|
|
857
|
+
const none = { journeyId, path, verifier: "none" };
|
|
858
|
+
const found = readCheckpointFile(path);
|
|
859
|
+
if (found.kind === "none")
|
|
860
|
+
return { ...none, receipt: "none", resume: { possible: false, refusal: "no-checkpoint" } };
|
|
861
|
+
if (found.kind === "not-regular")
|
|
862
|
+
return { ...none, receipt: "not-regular-file", detail: "something other than a regular file is at this path", resume: { possible: false, refusal: "not-regular-file" } };
|
|
863
|
+
if (found.kind === "corrupt")
|
|
864
|
+
return { ...none, receipt: "corrupt", detail: found.detail, resume: { possible: false, refusal: "corrupt" } };
|
|
865
|
+
const { body, mac } = found;
|
|
866
|
+
if (body.journeyId !== journeyId)
|
|
867
|
+
return { ...none, receipt: "corrupt", detail: "it belongs to another Journey", resume: { possible: false, refusal: "corrupt" } };
|
|
868
|
+
const key = readCheckpointKey(keyFile);
|
|
869
|
+
if ("key" in key && !macMatches(key.key, body, mac))
|
|
870
|
+
return { ...none, receipt: "integrity-failed", detail: "it does not carry this user's key", resume: { possible: false, refusal: "integrity" } };
|
|
871
|
+
const verifier = verifierStateOf(body);
|
|
872
|
+
const refusal = !("key" in key)
|
|
873
|
+
? "key-unavailable"
|
|
874
|
+
: verifier === "live"
|
|
875
|
+
? "live-verifier"
|
|
876
|
+
: body.state === "running" && body.verifier.host !== hostname()
|
|
877
|
+
? "other-host"
|
|
878
|
+
: finishedForGood(body)
|
|
879
|
+
? "finished"
|
|
880
|
+
: unknownRecordKind(body) !== undefined
|
|
881
|
+
? "unknown-record"
|
|
882
|
+
: ageRefusal(body) !== undefined
|
|
883
|
+
? "too-old"
|
|
884
|
+
: undefined;
|
|
885
|
+
return {
|
|
886
|
+
journeyId,
|
|
887
|
+
path,
|
|
888
|
+
receipt: "key" in key ? "valid" : "unverified",
|
|
889
|
+
verifier,
|
|
890
|
+
resume: refusal === undefined ? { possible: true } : { possible: false, refusal },
|
|
891
|
+
progress: {
|
|
892
|
+
state: body.state,
|
|
893
|
+
...optional("verdict", body.verdict),
|
|
894
|
+
phase: body.phase,
|
|
895
|
+
completed: finishedForGood(body) ? body.completed.length : body.completed.filter(reusableOnResume).length,
|
|
896
|
+
toRunAgain: finishedForGood(body) ? 0 : body.completed.filter((p) => !reusableOnResume(p)).length,
|
|
897
|
+
total: body.total,
|
|
898
|
+
currentPair: body.currentPair,
|
|
899
|
+
replay: body.replay,
|
|
900
|
+
reusedPairs: body.reusedPairs,
|
|
901
|
+
journeyHash: body.journeyHash,
|
|
902
|
+
planDigest: body.planDigest,
|
|
903
|
+
startedAt: body.startedAt,
|
|
904
|
+
updatedAt: body.updatedAt,
|
|
905
|
+
lastResultClass: body.completed.at(-1)?.outcome ?? null,
|
|
906
|
+
completedPairs: body.completed.map((p) => ({ id: p.id, resultClass: p.outcome, finishedAt: p.finishedAt })),
|
|
907
|
+
verifierPid: body.verifier.pid,
|
|
908
|
+
verifierStartToken: body.verifier.startToken,
|
|
909
|
+
build: body.build,
|
|
910
|
+
taint: { restoreFailed: body.taint.restoreFailed, notIsolated: body.taint.notIsolatedBy !== null || body.taint.isolation !== null },
|
|
911
|
+
},
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
/** One human line for `--status`. */
|
|
915
|
+
export function verifyProgressLine(s) {
|
|
916
|
+
if (s.progress === undefined)
|
|
917
|
+
return `journey '${s.journeyId}': ${s.receipt === "none" ? "no progress receipt" : `progress receipt ${s.receipt}${s.detail === undefined ? "" : `: ${s.detail}`}`}\n`;
|
|
918
|
+
const p = s.progress;
|
|
919
|
+
const current = p.currentPair === null ? p.phase : `pair ${p.currentPair}`;
|
|
920
|
+
const resume = s.resume.possible ? "resumable" : `not resumable (${s.resume.refusal ?? "unknown"})`;
|
|
921
|
+
return `journey '${s.journeyId}': verifier ${s.verifier} (pid ${p.verifierPid}), ${p.state}${p.verdict === undefined ? "" : ` ${p.verdict}`}, ${p.completed}/${p.total} pairs${p.toRunAgain === 0 ? "" : ` (${p.toRunAgain} more ran, errored or short of a control, and a resume runs them again)`}, at ${current}, replay ${p.replay}, updated ${p.updatedAt}, ${resume}\n`;
|
|
922
|
+
}
|
|
923
|
+
//# sourceMappingURL=journey-verify-checkpoint.js.map
|