@intentius/chant 0.124.25 → 0.124.26
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/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +4 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +4 -2
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +39 -4
- package/dist/gate-decision.d.ts +1 -1
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +1 -1
- package/dist/lifecycle/gate-audit.d.ts +7 -1
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/lifecycle/gate-audit.js +40 -3
- package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
- package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
- package/dist/lifecycle/gate-checkpoint.js +103 -0
- package/dist/lifecycle/gate-ledger.d.ts +41 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.js +18 -0
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +2 -0
- package/dist/lint/rules/op/index.d.ts +6 -2
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +7 -3
- package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
- package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
- package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
- package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
- package/dist/op/activity-contract.d.ts +14 -0
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-contract.js +16 -0
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +13 -0
- package/dist/op/gate-alias.d.ts +57 -0
- package/dist/op/gate-alias.d.ts.map +1 -0
- package/dist/op/gate-alias.js +127 -0
- package/dist/op/gate-checkpoint.d.ts +123 -0
- package/dist/op/gate-checkpoint.d.ts.map +1 -0
- package/dist/op/gate-checkpoint.js +280 -0
- package/dist/op/gate.d.ts +54 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +142 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +2 -1
- package/dist/op/local-executor.d.ts +57 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +136 -6
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/local-output.js +20 -2
- package/dist/op/op-ir.d.ts +4 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-ir.js +4 -0
- package/dist/op/runtime.d.ts +15 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +5 -0
- package/dist/op/types.d.ts +31 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +214 -212
- package/dist/workspace/trust/gate-seal.d.ts +32 -1
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.js +41 -2
- package/dist/workspace/trust/seal.d.ts +1 -1
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.ts +9 -2
- package/src/cli/commands/lint.ts +4 -2
- package/src/cli/handlers/approve.ts +46 -4
- package/src/cli/handlers/operator.test.ts +91 -0
- package/src/gate-decision.ts +2 -0
- package/src/lifecycle/gate-audit.ts +41 -4
- package/src/lifecycle/gate-checkpoint.test.ts +128 -0
- package/src/lifecycle/gate-checkpoint.ts +133 -0
- package/src/lifecycle/gate-ledger.ts +61 -0
- package/src/lifecycle/run-ledger.ts +4 -0
- package/src/lint/rules/op/index.ts +9 -3
- package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
- package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
- package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
- package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
- package/src/op/activity-contract.ts +22 -1
- package/src/op/builders.ts +21 -1
- package/src/op/gate-alias.e2e.test.ts +78 -0
- package/src/op/gate-alias.test.ts +350 -0
- package/src/op/gate-alias.ts +137 -0
- package/src/op/gate-checkpoint.test.ts +284 -0
- package/src/op/gate-checkpoint.ts +322 -0
- package/src/op/gate.ts +198 -4
- package/src/op/index.ts +4 -1
- package/src/op/local-executor.ts +180 -7
- package/src/op/local-output.ts +18 -2
- package/src/op/op-ir.test.ts +25 -0
- package/src/op/op-ir.ts +8 -0
- package/src/op/runtime.ts +8 -0
- package/src/op/runtimes/local.ts +5 -0
- package/src/op/types.ts +32 -0
- package/src/workspace/trust/gate-seal.test.ts +29 -2
- package/src/workspace/trust/gate-seal.ts +46 -2
- package/src/workspace/trust/seal.ts +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate aliases (#3949, epic #3946): the names a gate had before its Op was
|
|
3
|
+
* restructured, so the approvals already on the ledger under them still
|
|
4
|
+
* count.
|
|
5
|
+
*
|
|
6
|
+
* A gate's identity is its name and its Op's: the ledger is per Op
|
|
7
|
+
* (`_gates/<op>.jsonl`) and every decision filters by gate. Merging three
|
|
8
|
+
* per-tier Ops into one wave-driven Op renames every gate and orphans the
|
|
9
|
+
* approvals recorded under the old names. A gate declares those names as
|
|
10
|
+
* `aliases: [{ op?, gate }]`, `op` defaulting to its own Op.
|
|
11
|
+
*
|
|
12
|
+
* `evaluateGate` (`./gate.ts`) decides which approvals an alias admits, and
|
|
13
|
+
* writes each one it counts again under the new name with `carriedFrom`
|
|
14
|
+
* naming the line it came from. This module holds the parts that read only
|
|
15
|
+
* the Op's config: where its gates are, what an alias resolves to, what the
|
|
16
|
+
* builder and OPS017 refuse, and which gate aliases an old name, which
|
|
17
|
+
* `chant approve` asks before it records an approval under that name.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { GateAlias, GateStep, OpConfig, StepDefinition } from "./types";
|
|
21
|
+
import { gateName } from "./gate-name";
|
|
22
|
+
|
|
23
|
+
/** An alias with its Op filled in. */
|
|
24
|
+
export interface ResolvedGateAlias {
|
|
25
|
+
op: string;
|
|
26
|
+
gate: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Every gate step of `config`, main phases first, then `onFailure`, inside effects too, with the phase it sits in. */
|
|
30
|
+
export function gateStepsOf(config: Pick<OpConfig, "phases" | "onFailure">): Array<{ phase: string; step: GateStep }> {
|
|
31
|
+
const out: Array<{ phase: string; step: GateStep }> = [];
|
|
32
|
+
const visit = (phase: string, step: StepDefinition) => {
|
|
33
|
+
if (step.kind === "gate") out.push({ phase, step });
|
|
34
|
+
else if (step.kind === "effect") for (const nested of step.steps) visit(phase, nested);
|
|
35
|
+
};
|
|
36
|
+
for (const phase of [...config.phases, ...(config.onFailure ?? [])]) for (const step of phase.steps) visit(phase.name, step);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A gate's aliases with each `op` filled in from `op`, the gate's own Op. */
|
|
41
|
+
export function resolveGateAliases(step: Pick<GateStep, "aliases">, op: string): ResolvedGateAlias[] {
|
|
42
|
+
return (step.aliases ?? []).map((a) => ({ op: a.op ?? op, gate: a.gate }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Why a gate's `aliases` can't stand on the step itself, one line each. The builder throws the first. */
|
|
46
|
+
export function gateAliasProblems(aliases: readonly GateAlias[], step: Pick<GateStep, "plan" | "point">): string[] {
|
|
47
|
+
const problems: string[] = [];
|
|
48
|
+
if (step.plan === undefined) {
|
|
49
|
+
problems.push("aliases need a plan: an alias admits only approvals of the plan this run planned, and a gate with no plan has none");
|
|
50
|
+
}
|
|
51
|
+
if (step.point !== undefined) problems.push("a gate that asks a decision point can't take aliases");
|
|
52
|
+
const seen = new Set<string>();
|
|
53
|
+
for (const a of aliases) {
|
|
54
|
+
if (typeof a?.gate !== "string" || a.gate === "") {
|
|
55
|
+
problems.push("each alias names a gate");
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (a.op !== undefined && (typeof a.op !== "string" || a.op === "")) problems.push(`alias ${JSON.stringify(a.gate)} names an empty op`);
|
|
59
|
+
const key = `${a.op ?? ""}/${a.gate}`;
|
|
60
|
+
if (seen.has(key)) problems.push(`alias ${a.op ? `${a.op}/` : ""}${a.gate} is listed twice`);
|
|
61
|
+
seen.add(key);
|
|
62
|
+
}
|
|
63
|
+
return problems;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The gate of `ops` whose aliases include `op`/`gate`, or undefined when none does. */
|
|
67
|
+
export function aliasingGate(
|
|
68
|
+
ops: Iterable<Pick<OpConfig, "name" | "phases" | "onFailure"> | undefined>,
|
|
69
|
+
op: string,
|
|
70
|
+
gate: string,
|
|
71
|
+
): { op: string; gate: string } | undefined {
|
|
72
|
+
for (const config of ops) {
|
|
73
|
+
if (!config || !Array.isArray(config.phases)) continue;
|
|
74
|
+
for (const { step } of gateStepsOf(config)) {
|
|
75
|
+
if (resolveGateAliases(step, config.name).some((a) => a.op === op && a.gate === gate)) return { op: config.name, gate: gateName(step) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One OPS017 finding: the Op, phase and gate it is about, and what is wrong. */
|
|
82
|
+
export interface GateAliasIssue {
|
|
83
|
+
opName: string;
|
|
84
|
+
phase: string;
|
|
85
|
+
gate: string;
|
|
86
|
+
message: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* OPS017's check (#3949) over every Op in a build. An alias must stand on a
|
|
91
|
+
* gate bound to a plan that asks no decision point, must not name a gate
|
|
92
|
+
* that still exists, must not name the gate itself, and must not be claimed
|
|
93
|
+
* by two gates, since an approval would then be carried twice. When the
|
|
94
|
+
* workspace's `identity.gates` names an alias, the new gate must be named
|
|
95
|
+
* too, with the alias's class when it has one: `identityGates` maps each
|
|
96
|
+
* gate the declaration names to its class (null for any signer). Absent,
|
|
97
|
+
* outside a workspace, that part is skipped.
|
|
98
|
+
*/
|
|
99
|
+
export function validateGateAliases(
|
|
100
|
+
ops: ReadonlyArray<Pick<OpConfig, "name" | "phases" | "onFailure">>,
|
|
101
|
+
identityGates?: ReadonlyMap<string, string | null>,
|
|
102
|
+
): GateAliasIssue[] {
|
|
103
|
+
const issues: GateAliasIssue[] = [];
|
|
104
|
+
const existing = new Set<string>();
|
|
105
|
+
for (const config of ops) for (const { step } of gateStepsOf(config)) existing.add(`${config.name}/${gateName(step)}`);
|
|
106
|
+
const claimed = new Map<string, string>();
|
|
107
|
+
|
|
108
|
+
for (const config of ops) {
|
|
109
|
+
for (const { phase, step } of gateStepsOf(config)) {
|
|
110
|
+
if (step.aliases === undefined) continue;
|
|
111
|
+
const name = gateName(step);
|
|
112
|
+
const issue = (message: string) => issues.push({ opName: config.name, phase, gate: name, message });
|
|
113
|
+
for (const problem of gateAliasProblems(step.aliases, step)) issue(problem);
|
|
114
|
+
for (const alias of resolveGateAliases(step, config.name)) {
|
|
115
|
+
if (alias.gate === "") continue;
|
|
116
|
+
const key = `${alias.op}/${alias.gate}`;
|
|
117
|
+
if (alias.op === config.name && alias.gate === name) {
|
|
118
|
+
issue(`alias ${key} is the gate's own name`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (existing.has(key)) issue(`alias ${key} is still a gate; remove that gate, or this alias, so one approval doesn't answer two gates`);
|
|
122
|
+
const owner = claimed.get(key);
|
|
123
|
+
if (owner !== undefined && owner !== `${config.name}/${name}`) issue(`alias ${key} is also an alias of ${owner}, so its approvals would be carried twice`);
|
|
124
|
+
else claimed.set(key, `${config.name}/${name}`);
|
|
125
|
+
if (identityGates?.has(alias.gate)) {
|
|
126
|
+
const want = identityGates.get(alias.gate) ?? null;
|
|
127
|
+
if (!identityGates.has(name)) {
|
|
128
|
+
issue(`identity.gates names its alias ${alias.gate}, so gate ${name} needs an identity.gates entry too, or approvals that needed a seal would count without one`);
|
|
129
|
+
} else if (want !== null && identityGates.get(name) !== want) {
|
|
130
|
+
issue(`identity.gates.${alias.gate} needs class ${want}, and identity.gates.${name} needs ${identityGates.get(name) ?? "any signer"}; the gate must keep its alias's class`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return issues;
|
|
137
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3947: a run resumes at the approved gate instead of re-running from the
|
|
3
|
+
* first phase. Each test drives the loop through the executor against an
|
|
4
|
+
* in-memory gate ledger: a run stops at the gate and stores a checkpoint, an
|
|
5
|
+
* approval copies its digest, and the next run either picks up at the gate
|
|
6
|
+
* or starts at the first phase and says why.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { runOpLocally, type OpRunResult } from "./local-executor";
|
|
11
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
12
|
+
import { stepOutput } from "./step-output-ref";
|
|
13
|
+
import { activityContract } from "./activity-contract";
|
|
14
|
+
import { resumeGatesOf, validateGateResume } from "./gate-checkpoint";
|
|
15
|
+
import { renderHuman } from "./local-output";
|
|
16
|
+
import { gateCheckpointDigest, gateCheckpointText, parseGateCheckpoint } from "../lifecycle/gate-checkpoint";
|
|
17
|
+
import type { ActivityFn } from "./activity-registry";
|
|
18
|
+
import type { OpConfig } from "./types";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
21
|
+
const PLAN = `jcs1-sha256:${"a".repeat(64)}`;
|
|
22
|
+
const MOVED = `jcs1-sha256:${"b".repeat(64)}`;
|
|
23
|
+
const COMMIT = "1".repeat(40);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* An Op shaped like loomster's upgrade: a snapshot step whose id the apply
|
|
27
|
+
* and the rollback both read, a plan, the gate, then the apply. `noise` runs
|
|
28
|
+
* before the gate and nothing reads it, so the checkpoint leaves it out.
|
|
29
|
+
*/
|
|
30
|
+
function upgradeOp(gate: Record<string, unknown> = {}): OpConfig {
|
|
31
|
+
return {
|
|
32
|
+
name: "upgrade",
|
|
33
|
+
overview: "upgrade a stack",
|
|
34
|
+
phases: [
|
|
35
|
+
{
|
|
36
|
+
name: "Prepare",
|
|
37
|
+
steps: [
|
|
38
|
+
{ kind: "activity", id: "snapshot", fn: "snapshot" },
|
|
39
|
+
{ kind: "activity", id: "noise", fn: "noise" },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{ name: "Plan", steps: [{ kind: "activity", id: "plan", fn: "plan", args: { stack: stepOutput("snapshot", "stack") } }] },
|
|
43
|
+
{
|
|
44
|
+
name: "Gate",
|
|
45
|
+
steps: [{ kind: "gate", gate: "approve-upgrade", plan: stepOutput("plan", "planDigest"), resume: true, ...gate }],
|
|
46
|
+
},
|
|
47
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "apply", args: { snapshot: stepOutput("snapshot", "id") } }] },
|
|
48
|
+
],
|
|
49
|
+
onFailure: [{ name: "Rollback", steps: [{ kind: "activity", fn: "rollback", args: { to: stepOutput("snapshot", "id") } }] }],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Activities that count their calls; `plan` returns whatever `digest.value` holds. */
|
|
54
|
+
function harness() {
|
|
55
|
+
const calls: Record<string, Array<Record<string, unknown>>> = {};
|
|
56
|
+
const digest = { value: PLAN };
|
|
57
|
+
let snapshots = 0;
|
|
58
|
+
const track = (name: string, fn: ActivityFn): ActivityFn => async (args, signal) => {
|
|
59
|
+
(calls[name] ??= []).push(args);
|
|
60
|
+
return fn(args, signal);
|
|
61
|
+
};
|
|
62
|
+
const activities = new Map<string, ActivityFn>([
|
|
63
|
+
["snapshot", track("snapshot", async () => ({ id: `snap-${++snapshots}`, stack: "web" }))],
|
|
64
|
+
["noise", track("noise", async () => ({ big: "x".repeat(10) }))],
|
|
65
|
+
["plan", track("plan", async () => ({ planDigest: digest.value }))],
|
|
66
|
+
["apply", track("apply", async () => ({ ok: true }))],
|
|
67
|
+
["rollback", track("rollback", async () => ({ ok: true }))],
|
|
68
|
+
]);
|
|
69
|
+
return { calls, digest, activities };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const at = (minute: number) => `2026-10-12T12:${String(minute).padStart(2, "0")}:00.000Z`;
|
|
73
|
+
|
|
74
|
+
async function stopAtGate(config: OpConfig, h: ReturnType<typeof harness>, port = memoryGateLedgerPort()) {
|
|
75
|
+
const first = await runOpLocally(config, h.activities, {}, undefined, { gates: port, now: at(0), runId: "run-1", commit: COMMIT });
|
|
76
|
+
return { port, first };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function approve(port: ReturnType<typeof memoryGateLedgerPort>, first: OpRunResult, over: Record<string, unknown> = {}) {
|
|
80
|
+
await port.appendResolution!({
|
|
81
|
+
op: "upgrade",
|
|
82
|
+
gate: "approve-upgrade",
|
|
83
|
+
resolvedBy: "alex",
|
|
84
|
+
timestamp: at(5),
|
|
85
|
+
planDigest: first.gate!.planDigest!,
|
|
86
|
+
...(first.gate!.checkpointDigest ? { checkpointDigest: first.gate!.checkpointDigest } : {}),
|
|
87
|
+
...over,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("which outputs cross a gate that resumes", () => {
|
|
92
|
+
test("the checkpoint stores what a later step or the rollback reads, and not the plan step or an unread output", () => {
|
|
93
|
+
const [gate] = resumeGatesOf(upgradeOp());
|
|
94
|
+
expect(gate!.name).toBe("approve-upgrade");
|
|
95
|
+
expect(gate!.planStep?.step.id).toBe("plan");
|
|
96
|
+
expect(gate!.stored).toEqual(["snapshot"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a gate without resume, or in an Op with a work lease, is not one", () => {
|
|
100
|
+
const plain = upgradeOp({ resume: false });
|
|
101
|
+
expect(resumeGatesOf(plain)).toEqual([]);
|
|
102
|
+
expect(resumeGatesOf({ ...upgradeOp(), workLease: { item: "x" } })).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("a run that stops at a gate that resumes", () => {
|
|
107
|
+
test("stores a checkpoint of the crossing outputs and puts its digest on the pending fact", async () => {
|
|
108
|
+
const h = harness();
|
|
109
|
+
const { port, first } = await stopAtGate(upgradeOp(), h);
|
|
110
|
+
expect(first.status).toBe("gated");
|
|
111
|
+
const text = port.checkpoints.get("upgrade/run-1")!;
|
|
112
|
+
const checkpoint = parseGateCheckpoint(text)!;
|
|
113
|
+
expect(checkpoint).toMatchObject({ op: "upgrade", gate: "approve-upgrade", runId: "run-1", commit: COMMIT, planDigest: PLAN });
|
|
114
|
+
expect(checkpoint.outputs).toEqual({ snapshot: { id: "snap-1", stack: "web" } });
|
|
115
|
+
expect(first.gate?.checkpointDigest).toBe(gateCheckpointDigest(checkpoint));
|
|
116
|
+
|
|
117
|
+
const lines: string[] = [];
|
|
118
|
+
renderHuman(first, (l) => lines.push(l));
|
|
119
|
+
expect(lines.some((l) => l.includes("resume :") && l.includes(first.gate!.checkpointDigest!))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a later run that finds the fact standing writes no second checkpoint", async () => {
|
|
123
|
+
const h = harness();
|
|
124
|
+
const { port } = await stopAtGate(upgradeOp(), h);
|
|
125
|
+
const again = await runOpLocally(upgradeOp(), h.activities, {}, undefined, { gates: port, now: at(1), runId: "run-2", commit: COMMIT });
|
|
126
|
+
expect(again.status).toBe("gated");
|
|
127
|
+
expect(port.appended).toHaveLength(1);
|
|
128
|
+
expect([...port.checkpoints.keys()]).toEqual(["upgrade/run-1"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a secret output is never stored, and the run says the gate won't resume", async () => {
|
|
132
|
+
const h = harness();
|
|
133
|
+
const contracts = new Map([["snapshot", activityContract("snapshot", z.object({}), z.object({ id: z.string(), stack: z.string() }), { secret: ["id"] })]]);
|
|
134
|
+
const port = memoryGateLedgerPort();
|
|
135
|
+
const first = await runOpLocally(upgradeOp(), h.activities, {}, undefined, { gates: port, now: at(0), runId: "run-1", commit: COMMIT, contracts });
|
|
136
|
+
expect(first.status).toBe("gated");
|
|
137
|
+
expect(port.checkpoints.size).toBe(0);
|
|
138
|
+
expect(first.gate?.checkpointDigest).toBeUndefined();
|
|
139
|
+
expect(first.resumeNotes?.[0]).toContain(`step "snapshot"'s output "id" is secret`);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("the run after the approval", () => {
|
|
144
|
+
test("resumes at the gate: re-runs only the plan step, and the apply and rollback read the checkpoint", async () => {
|
|
145
|
+
const h = harness();
|
|
146
|
+
const { port, first } = await stopAtGate(upgradeOp(), h);
|
|
147
|
+
await approve(port, first);
|
|
148
|
+
|
|
149
|
+
const second = await runOpLocally(upgradeOp(), h.activities, {}, undefined, { gates: port, now: at(10), runId: "run-2", commit: COMMIT });
|
|
150
|
+
expect(second.status).toBe("ok");
|
|
151
|
+
expect(second.resumed).toEqual({ gate: "approve-upgrade", run: "run-1", checkpoint: first.gate!.checkpointDigest });
|
|
152
|
+
expect(h.calls.snapshot).toHaveLength(1);
|
|
153
|
+
expect(h.calls.noise).toHaveLength(1);
|
|
154
|
+
expect(h.calls.plan).toHaveLength(2);
|
|
155
|
+
expect(h.calls.plan![1]).toEqual({ stack: "web" });
|
|
156
|
+
// The apply got the first run's snapshot, not a fresh one.
|
|
157
|
+
expect(h.calls.apply).toEqual([{ snapshot: "snap-1" }]);
|
|
158
|
+
|
|
159
|
+
const snapshot = second.records.find((r) => r.fn === "snapshot");
|
|
160
|
+
expect(snapshot).toMatchObject({ status: "skipped", resumed: { run: "run-1", gate: "approve-upgrade" } });
|
|
161
|
+
expect(second.records.find((r) => r.fn === "gate:approve-upgrade")?.status).toBe("ok");
|
|
162
|
+
expect(second.record.resumed).toEqual(second.resumed);
|
|
163
|
+
expect(second.record.phases[0]?.steps[0]?.resumed).toEqual({ run: "run-1", gate: "approve-upgrade" });
|
|
164
|
+
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
renderHuman(second, (l) => lines.push(l));
|
|
167
|
+
expect(lines[0]).toContain(`[resume] at gate "approve-upgrade", from run run-1's checkpoint`);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a failure after the resumed gate compensates with the checkpoint's value", async () => {
|
|
171
|
+
const h = harness();
|
|
172
|
+
const { port, first } = await stopAtGate(upgradeOp(), h);
|
|
173
|
+
await approve(port, first);
|
|
174
|
+
h.activities.set("apply", async () => {
|
|
175
|
+
throw new Error("apply failed");
|
|
176
|
+
});
|
|
177
|
+
await expect(
|
|
178
|
+
runOpLocally(upgradeOp(), h.activities, {}, undefined, { gates: port, now: at(10), runId: "run-2", commit: COMMIT }),
|
|
179
|
+
).rejects.toThrow();
|
|
180
|
+
expect(h.calls.rollback).toEqual([{ to: "snap-1" }]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("starts at the first phase when the plan moved since the approval", async () => {
|
|
184
|
+
const h = harness();
|
|
185
|
+
const { port, first } = await stopAtGate(upgradeOp(), h);
|
|
186
|
+
await approve(port, first);
|
|
187
|
+
h.digest.value = MOVED;
|
|
188
|
+
|
|
189
|
+
const second = await runOpLocally(upgradeOp(), h.activities, {}, undefined, { gates: port, now: at(10), runId: "run-2", commit: COMMIT });
|
|
190
|
+
expect(second.status).toBe("gated");
|
|
191
|
+
expect(second.resumed).toBeUndefined();
|
|
192
|
+
expect(second.resumeNotes?.[0]).toContain("the plan moved since the approval");
|
|
193
|
+
expect(h.calls.snapshot).toHaveLength(2);
|
|
194
|
+
expect(h.calls.apply).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("starts at the first phase at another commit", async () => {
|
|
198
|
+
const h = harness();
|
|
199
|
+
const { port, first } = await stopAtGate(upgradeOp(), h);
|
|
200
|
+
await approve(port, first);
|
|
201
|
+
|
|
202
|
+
const second = await runOpLocally(upgradeOp(), h.activities, {}, undefined, { gates: port, now: at(10), runId: "run-2", commit: "2".repeat(40) });
|
|
203
|
+
expect(second.status).toBe("ok");
|
|
204
|
+
expect(second.resumed).toBeUndefined();
|
|
205
|
+
expect(second.resumeNotes?.[0]).toContain("was at commit 111111111111, and this one is at 222222222222");
|
|
206
|
+
expect(h.calls.snapshot).toHaveLength(2);
|
|
207
|
+
expect(h.calls.apply).toEqual([{ snapshot: "snap-2" }]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("starts at the first phase when the checkpoint file changed after the approval", async () => {
|
|
211
|
+
const h = harness();
|
|
212
|
+
const { port, first } = await stopAtGate(upgradeOp(), h);
|
|
213
|
+
await approve(port, first);
|
|
214
|
+
const tampered = parseGateCheckpoint(port.checkpoints.get("upgrade/run-1")!)!;
|
|
215
|
+
tampered.outputs = { snapshot: { id: "snap-evil", stack: "web" } };
|
|
216
|
+
port.checkpoints.set("upgrade/run-1", gateCheckpointText(tampered));
|
|
217
|
+
|
|
218
|
+
const second = await runOpLocally(upgradeOp(), h.activities, {}, undefined, { gates: port, now: at(10), runId: "run-2", commit: COMMIT });
|
|
219
|
+
expect(second.resumeNotes?.[0]).toContain("does not match the digest the approver saw");
|
|
220
|
+
expect(h.calls.apply).toEqual([{ snapshot: "snap-2" }]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("starts at the first phase in another environment, or once the pending fact expired", async () => {
|
|
224
|
+
const h = harness();
|
|
225
|
+
const { port, first } = await stopAtGate(upgradeOp(), h);
|
|
226
|
+
await approve(port, first);
|
|
227
|
+
|
|
228
|
+
const other = await runOpLocally(upgradeOp(), h.activities, {}, undefined, { gates: port, now: at(10), runId: "run-2", commit: COMMIT, env: "prod" });
|
|
229
|
+
expect(other.resumeNotes?.[0]).toContain("for environment (none), and this one is for prod");
|
|
230
|
+
|
|
231
|
+
const late = await runOpLocally(upgradeOp(), h.activities, {}, undefined, { gates: port, now: "2026-10-20T00:00:00.000Z", runId: "run-3", commit: COMMIT });
|
|
232
|
+
expect(late.resumed).toBeUndefined();
|
|
233
|
+
expect(late.resumeNotes?.[0]).toContain("its pending fact expired");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("an approval that copied no checkpoint passes the gate the old way, from the first phase", async () => {
|
|
237
|
+
const h = harness();
|
|
238
|
+
const { port, first } = await stopAtGate(upgradeOp(), h);
|
|
239
|
+
await port.appendResolution!({ op: "upgrade", gate: "approve-upgrade", resolvedBy: "alex", timestamp: at(5), planDigest: first.gate!.planDigest! });
|
|
240
|
+
|
|
241
|
+
const second = await runOpLocally(upgradeOp(), h.activities, {}, undefined, { gates: port, now: at(10), runId: "run-2", commit: COMMIT });
|
|
242
|
+
expect(second.status).toBe("ok");
|
|
243
|
+
expect(second.resumed).toBeUndefined();
|
|
244
|
+
expect(second.resumeNotes).toBeUndefined();
|
|
245
|
+
expect(h.calls.snapshot).toHaveLength(2);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a gate without resume starts at the first phase as before and stores nothing", async () => {
|
|
249
|
+
const h = harness();
|
|
250
|
+
const port = memoryGateLedgerPort();
|
|
251
|
+
const first = await runOpLocally(upgradeOp({ resume: false }), h.activities, {}, undefined, { gates: port, now: at(0), runId: "run-1", commit: COMMIT });
|
|
252
|
+
expect(first.gate?.checkpointDigest).toBeUndefined();
|
|
253
|
+
expect(port.checkpoints.size).toBe(0);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("OPS016's check", () => {
|
|
258
|
+
test("a well-placed gate passes", () => {
|
|
259
|
+
expect(validateGateResume(upgradeOp())).toEqual([]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("refuses a resume gate in onFailure, inside an effect, with a decision point, or under a work lease", () => {
|
|
263
|
+
const config = upgradeOp({ point: "prod-apply" });
|
|
264
|
+
config.onFailure = [{ name: "Rollback", steps: [{ kind: "gate", gate: "late", resume: true }] }];
|
|
265
|
+
config.phases.push({
|
|
266
|
+
name: "Effect",
|
|
267
|
+
steps: [{ kind: "effect", receipt: { name: "r" } as never, steps: [{ kind: "gate", gate: "inner", resume: true }] }],
|
|
268
|
+
});
|
|
269
|
+
const messages = validateGateResume({ ...config, workLease: { item: "x" } }).map((i) => i.message);
|
|
270
|
+
expect(messages).toContain("resume is supported only on a gate at the top of a main phase, not in onFailure or inside an effect");
|
|
271
|
+
expect(messages).toContain("a gate can't resume in an Op that runs under a workLease");
|
|
272
|
+
expect(messages).toContain("a gate that asks a decision point can't resume");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("refuses a plan step that doesn't run before the gate", () => {
|
|
276
|
+
const config = upgradeOp({ plan: stepOutput("apply-later", "planDigest") });
|
|
277
|
+
expect(validateGateResume(config)[0]?.message).toContain(`its plan names step "apply-later"`);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("refuses a secret output read after the gate", () => {
|
|
281
|
+
const contracts = new Map([["snapshot", activityContract("snapshot", z.object({}), z.object({ id: z.string() }), { secret: true })]]);
|
|
282
|
+
expect(validateGateResume(upgradeOp(), contracts)[0]?.message).toContain(`can't resume: step "snapshot"'s output "id" is secret`);
|
|
283
|
+
});
|
|
284
|
+
});
|