@intentius/chant 0.124.24 → 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/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +21 -7
- package/dist/cli/mcp/workspace-tools.d.ts +3 -3
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +27 -4
- package/dist/effect-receipt.d.ts +13 -0
- package/dist/effect-receipt.d.ts.map +1 -1
- package/dist/effect-receipt.js +19 -0
- 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/lexicon-module.d.ts +7 -1
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +7 -2
- 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/receipt-plan.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.js +4 -2
- 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 +200 -11
- 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/receipt-store.d.ts +5 -0
- package/dist/op/receipt-store.d.ts.map +1 -1
- package/dist/op/receipt-store.js +4 -3
- 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/step-output-ref.d.ts +26 -18
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/step-output-ref.js +86 -63
- package/dist/op/types.d.ts +31 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +223 -221
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/agent-cli.js +3 -0
- package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/dist/workspace/conformance/writer.js +1 -1
- package/dist/workspace/decide.d.ts +51 -9
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/decide.js +125 -14
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +20 -11
- package/dist/workspace/point-answer.schema.json +16 -1
- package/dist/workspace/points-cli.d.ts +5 -3
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points-cli.js +16 -7
- package/dist/workspace/points-write.schema.json +39 -4
- package/dist/workspace/points.schema.json +42 -3
- package/dist/workspace/reason-codes.d.ts +2 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +2 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.js +27 -7
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records-write.js +2 -3
- package/dist/workspace/records.d.ts +10 -4
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/records.js +16 -3
- package/dist/workspace/records.schema.json +2 -1
- 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/dist/workspace/trust/verify-cli.d.ts.map +1 -1
- package/dist/workspace/trust/verify-cli.js +2 -1
- package/dist/workspace/trust/verify.d.ts +22 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/trust/verify.js +94 -14
- package/dist/workspace/write-scope.d.ts +3 -1
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -4
- package/package.json +1 -1
- package/src/cli/commands/build.ts +9 -2
- package/src/cli/commands/lint.ts +4 -2
- package/src/cli/compiled-cli.e2e.test.ts +45 -1
- package/src/cli/handlers/approve.ts +46 -4
- package/src/cli/handlers/operator.test.ts +91 -0
- package/src/cli/main.test.ts +10 -0
- package/src/cli/main.ts +21 -7
- package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +27 -4
- package/src/effect-receipt.ts +22 -0
- package/src/gate-decision.ts +2 -0
- package/src/lexicon-module.ts +9 -2
- 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/receipt-plan.test.ts +9 -0
- package/src/lifecycle/receipt-plan.ts +4 -1
- 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/effect-step.test.ts +95 -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.test.ts +65 -0
- package/src/op/local-executor.ts +248 -12
- 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/receipt-store.ts +13 -3
- package/src/op/runtime.ts +8 -0
- package/src/op/runtimes/local.ts +5 -0
- package/src/op/step-output-ref.test.ts +68 -24
- package/src/op/step-output-ref.ts +85 -65
- package/src/op/types.ts +32 -0
- package/src/workspace/agent-cli.ts +2 -0
- package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/src/workspace/conformance/writer.ts +1 -1
- package/src/workspace/decide.test.ts +87 -3
- package/src/workspace/decide.ts +142 -17
- package/src/workspace/lineage-upgrade.test.ts +21 -0
- package/src/workspace/lineage-upgrade.ts +19 -11
- package/src/workspace/point-answer.schema.json +16 -1
- package/src/workspace/points-cli.ts +16 -8
- package/src/workspace/points-write.schema.json +39 -4
- package/src/workspace/points.schema.json +42 -3
- package/src/workspace/reason-codes.ts +2 -1
- package/src/workspace/record-assets.test.ts +14 -0
- package/src/workspace/records-cli.ts +28 -7
- package/src/workspace/records-write-commands.e2e.test.ts +32 -0
- package/src/workspace/records-write.ts +2 -3
- package/src/workspace/records.schema.json +2 -1
- package/src/workspace/records.ts +17 -4
- 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
- package/src/workspace/trust/trust.test.ts +105 -0
- package/src/workspace/trust/verify-cli.ts +2 -1
- package/src/workspace/trust/verify.ts +106 -13
- package/src/workspace/write-scope.test.ts +18 -0
- package/src/workspace/write-scope.ts +16 -5
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resuming a run at an approved gate (#3947, epic #3946).
|
|
3
|
+
*
|
|
4
|
+
* Before this, a run that stopped at a gate kept nothing but its record:
|
|
5
|
+
* after `chant approve`, the next `chant run <op>` began again at the first
|
|
6
|
+
* phase and recomputed every step before the gate, so a value could not be
|
|
7
|
+
* carried across a gate with `stepOutput`. A gate with `resume: true` keeps
|
|
8
|
+
* the outputs the steps after it need, and the next run starts at the gate.
|
|
9
|
+
*
|
|
10
|
+
* Stopping. The run works out which outputs cross the gate from the Op's own
|
|
11
|
+
* references ({@link resumeGatesOf}): an output of a step before the gate
|
|
12
|
+
* that a step at or after it references, in the main phases, `onFailure` or
|
|
13
|
+
* the gate's own `approval` context. It writes them to a checkpoint
|
|
14
|
+
* (`../lifecycle/gate-checkpoint.ts`) and puts the checkpoint's digest on
|
|
15
|
+
* the pending fact. An output an activity contract marks secret is never
|
|
16
|
+
* stored, and the gate then doesn't resume ({@link checkpointBlocked}).
|
|
17
|
+
*
|
|
18
|
+
* Resuming. {@link decideGateResume} picks the last resume gate whose
|
|
19
|
+
* standing pending fact carries a checkpoint and has an approval that copied
|
|
20
|
+
* it. It resumes there only when the fact hasn't expired, the checkpoint file
|
|
21
|
+
* matches the digest the approver saw and names the fact's run, and this run
|
|
22
|
+
* is at the same commit and environment. Any failed check starts the run at
|
|
23
|
+
* the first phase and the reason is reported. The executor then re-runs the
|
|
24
|
+
* step that produced the gate's `plan`, and starts at the first phase if the
|
|
25
|
+
* digest moved, so live drift between the approval and the apply is still
|
|
26
|
+
* caught (`./local-executor.ts`).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ActivityStep, GateStep, OpConfig, StepDefinition } from "./types";
|
|
30
|
+
import { WORK_LEASE_STEP_ID } from "./types";
|
|
31
|
+
import { collectStepOutputRefs, isStepOutputRef, type StepOutputRef } from "./step-output-ref";
|
|
32
|
+
import { gateName } from "./gate-name";
|
|
33
|
+
import { readsSecretOutput, type ActivityContract, type ActivityContractIssue } from "./activity-contract";
|
|
34
|
+
import type { GateLedgerPort } from "./gate";
|
|
35
|
+
import { isPendingGateExpired, latestPendingGate, type PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
36
|
+
import { isApprovalSpent } from "../lifecycle/gate-applied";
|
|
37
|
+
import { samePlanDigest } from "../lifecycle/plan-digest";
|
|
38
|
+
import {
|
|
39
|
+
checkpointOutputs,
|
|
40
|
+
gateCheckpointDigest,
|
|
41
|
+
gateCheckpointPath,
|
|
42
|
+
isCheckpointRunId,
|
|
43
|
+
parseGateCheckpoint,
|
|
44
|
+
type GateCheckpoint,
|
|
45
|
+
} from "../lifecycle/gate-checkpoint";
|
|
46
|
+
|
|
47
|
+
/** A top-level activity step of a main phase, where it sits. */
|
|
48
|
+
interface Producer {
|
|
49
|
+
step: ActivityStep;
|
|
50
|
+
phaseIndex: number;
|
|
51
|
+
stepIndex: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A gate that resumes, and what resuming at it needs. */
|
|
55
|
+
export interface ResumeGate {
|
|
56
|
+
step: GateStep;
|
|
57
|
+
name: string;
|
|
58
|
+
phaseIndex: number;
|
|
59
|
+
/** The gate's index among its phase's steps. */
|
|
60
|
+
stepIndex: number;
|
|
61
|
+
/** The step the gate's `plan` names, which a resumed run runs again. Absent when the plan is a literal or there is none. */
|
|
62
|
+
planStep?: Producer;
|
|
63
|
+
/** Ids of the steps before the gate whose outputs the checkpoint stores, in authored order. The plan step is not among them. */
|
|
64
|
+
stored: string[];
|
|
65
|
+
/** The references that read a stored output, for the secret check. */
|
|
66
|
+
crossing: StepOutputRef[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The step-output references a step reads when it runs: its args, a gate's plan, approval context and point inputs, and an effect's receipt and nested steps. */
|
|
70
|
+
function refsOfStep(step: StepDefinition): StepOutputRef[] {
|
|
71
|
+
if (step.kind === "activity") return collectStepOutputRefs(step.args);
|
|
72
|
+
if (step.kind === "gate") {
|
|
73
|
+
const point = typeof step.point === "object" ? step.point.inputs : undefined;
|
|
74
|
+
return [
|
|
75
|
+
...(isStepOutputRef(step.plan) ? [step.plan] : []),
|
|
76
|
+
...collectStepOutputRefs(step.approval?.context),
|
|
77
|
+
...collectStepOutputRefs(point),
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
return [...collectStepOutputRefs(step.receipt), ...step.steps.flatMap(refsOfStep)];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every gate step of `config` with `resume: true` that isn't at the top of a main phase. */
|
|
84
|
+
function misplacedResumeGates(config: Pick<OpConfig, "phases" | "onFailure">): Array<{ phase: string; gate: GateStep }> {
|
|
85
|
+
const out: Array<{ phase: string; gate: GateStep }> = [];
|
|
86
|
+
for (const phase of config.phases) {
|
|
87
|
+
for (const step of phase.steps) {
|
|
88
|
+
if (step.kind === "effect") for (const n of step.steps) if (n.kind === "gate" && n.resume) out.push({ phase: phase.name, gate: n });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const phase of config.onFailure ?? []) {
|
|
92
|
+
for (const step of phase.steps) {
|
|
93
|
+
if (step.kind === "gate" && step.resume) out.push({ phase: phase.name, gate: step });
|
|
94
|
+
if (step.kind === "effect") for (const n of step.steps) if (n.kind === "gate" && n.resume) out.push({ phase: phase.name, gate: n });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The gates of `config` that resume, at the top of a main phase, in authored
|
|
102
|
+
* order, each with the outputs a checkpoint at it stores. A gate that asks a
|
|
103
|
+
* decision point, or one in an Op with a `workLease`, is left out: OPS016
|
|
104
|
+
* refuses both, and a run treats them as gates that don't resume.
|
|
105
|
+
*/
|
|
106
|
+
export function resumeGatesOf(config: Pick<OpConfig, "phases" | "onFailure" | "workLease">): ResumeGate[] {
|
|
107
|
+
if (config.workLease) return [];
|
|
108
|
+
const out: ResumeGate[] = [];
|
|
109
|
+
config.phases.forEach((phase, phaseIndex) => {
|
|
110
|
+
phase.steps.forEach((step, stepIndex) => {
|
|
111
|
+
if (step.kind !== "gate" || !step.resume || step.point !== undefined) return;
|
|
112
|
+
// The steps that have finished when the run reaches the gate. In a
|
|
113
|
+
// parallel phase the gates are decided before any activity starts.
|
|
114
|
+
const before = new Map<string, Producer>();
|
|
115
|
+
config.phases.forEach((p, pi) => {
|
|
116
|
+
if (pi > phaseIndex) return;
|
|
117
|
+
p.steps.forEach((s, si) => {
|
|
118
|
+
if (s.kind !== "activity" || !s.id) return;
|
|
119
|
+
if (pi === phaseIndex && (p.parallel || si >= stepIndex)) return;
|
|
120
|
+
before.set(s.id, { step: s, phaseIndex: pi, stepIndex: si });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
const planStep = isStepOutputRef(step.plan) ? before.get(step.plan.step) : undefined;
|
|
124
|
+
|
|
125
|
+
// What reads them: the gate and every step after it, the compensation
|
|
126
|
+
// phases, the work lease, and the plan step, which runs again.
|
|
127
|
+
const after: StepDefinition[] = [
|
|
128
|
+
...(phase.parallel ? phase.steps : phase.steps.slice(stepIndex)),
|
|
129
|
+
...config.phases.slice(phaseIndex + 1).flatMap((p) => p.steps),
|
|
130
|
+
...(config.onFailure ?? []).flatMap((p) => p.steps),
|
|
131
|
+
...(planStep ? [planStep.step] : []),
|
|
132
|
+
];
|
|
133
|
+
const crossing = after.flatMap(refsOfStep).filter((ref) =>
|
|
134
|
+
ref.step !== WORK_LEASE_STEP_ID && ref.step !== planStep?.step.id && before.has(ref.step),
|
|
135
|
+
);
|
|
136
|
+
const ids = new Set(crossing.map((r) => r.step));
|
|
137
|
+
const stored = [...before.values()]
|
|
138
|
+
.filter((p) => ids.has(p.step.id!))
|
|
139
|
+
.sort((a, b) => a.phaseIndex - b.phaseIndex || a.stepIndex - b.stepIndex)
|
|
140
|
+
.map((p) => p.step.id!);
|
|
141
|
+
out.push({ step, name: gateName(step), phaseIndex, stepIndex, ...(planStep ? { planStep } : {}), stored, crossing });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The step id that produced `ref`, and its activity, among the steps before `gate`. */
|
|
148
|
+
function producerOf(config: Pick<OpConfig, "phases">, ref: StepOutputRef): ActivityStep | undefined {
|
|
149
|
+
for (const phase of config.phases) {
|
|
150
|
+
for (const step of phase.steps) if (step.kind === "activity" && step.id === ref.step) return step;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Why a checkpoint at `gate` can't hold what the steps after it read, or
|
|
157
|
+
* undefined when it can (#3947): a reference to an output the producer's
|
|
158
|
+
* contract marks secret. Secret outputs are never stored.
|
|
159
|
+
*/
|
|
160
|
+
export function checkpointBlocked(
|
|
161
|
+
config: Pick<OpConfig, "phases">,
|
|
162
|
+
gate: ResumeGate,
|
|
163
|
+
contracts: ReadonlyMap<string, Pick<ActivityContract, "secret">> | undefined,
|
|
164
|
+
): string | undefined {
|
|
165
|
+
if (!contracts) return undefined;
|
|
166
|
+
for (const ref of gate.crossing) {
|
|
167
|
+
const producer = producerOf(config, ref);
|
|
168
|
+
if (producer && readsSecretOutput(contracts.get(producer.fn), ref.path)) {
|
|
169
|
+
const what = ref.path ? `output "${ref.path}"` : "output";
|
|
170
|
+
return `step "${ref.step}"'s ${what} is secret (the ${producer.fn} contract marks it), and a checkpoint never stores a secret`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* OPS016's check (#3947): what is wrong with the gates of `config` that set
|
|
178
|
+
* `resume`. A gate that resumes must sit at the top of a main phase, must not
|
|
179
|
+
* ask a decision point, must not be in an Op with a `workLease`, must name a
|
|
180
|
+
* plan step that runs before it when its plan is a reference, and must not
|
|
181
|
+
* need a secret output after it.
|
|
182
|
+
*/
|
|
183
|
+
export function validateGateResume(
|
|
184
|
+
config: Pick<OpConfig, "name" | "phases" | "onFailure" | "workLease">,
|
|
185
|
+
contracts?: ReadonlyMap<string, ActivityContract>,
|
|
186
|
+
): ActivityContractIssue[] {
|
|
187
|
+
const issues: ActivityContractIssue[] = [];
|
|
188
|
+
const issue = (phase: string, gate: GateStep, message: string) =>
|
|
189
|
+
issues.push({ opName: config.name, phase, fn: `gate:${gateName(gate)}`, message });
|
|
190
|
+
|
|
191
|
+
for (const { phase, gate } of misplacedResumeGates(config)) {
|
|
192
|
+
issue(phase, gate, "resume is supported only on a gate at the top of a main phase, not in onFailure or inside an effect");
|
|
193
|
+
}
|
|
194
|
+
config.phases.forEach((phase) => {
|
|
195
|
+
for (const step of phase.steps) {
|
|
196
|
+
if (step.kind !== "gate" || !step.resume) continue;
|
|
197
|
+
if (config.workLease) issue(phase.name, step, "a gate can't resume in an Op that runs under a workLease");
|
|
198
|
+
if (step.point !== undefined) issue(phase.name, step, "a gate that asks a decision point can't resume");
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
for (const gate of resumeGatesOf(config)) {
|
|
202
|
+
const phase = config.phases[gate.phaseIndex]!.name;
|
|
203
|
+
if (isStepOutputRef(gate.step.plan) && !gate.planStep) {
|
|
204
|
+
issue(phase, gate.step, `its plan names step "${gate.step.plan.step}", which must be an activity that runs before the gate at the top of a main phase, so a resumed run can run it again`);
|
|
205
|
+
}
|
|
206
|
+
const blocked = checkpointBlocked(config, gate, contracts);
|
|
207
|
+
if (blocked) issue(phase, gate.step, `can't resume: ${blocked}`);
|
|
208
|
+
}
|
|
209
|
+
return issues;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build the checkpoint a run stores when it stops at `gate`, or say why it
|
|
214
|
+
* stores none: a secret output, an unknown commit, a run id that can't name
|
|
215
|
+
* a file, or an output JSON can't hold.
|
|
216
|
+
*/
|
|
217
|
+
export function buildGateCheckpoint(
|
|
218
|
+
config: Pick<OpConfig, "name" | "phases">,
|
|
219
|
+
gate: ResumeGate,
|
|
220
|
+
input: {
|
|
221
|
+
runId: string;
|
|
222
|
+
commit: string | undefined;
|
|
223
|
+
environment?: string;
|
|
224
|
+
planDigest?: string;
|
|
225
|
+
outputs: ReadonlyMap<string, unknown>;
|
|
226
|
+
contracts?: ReadonlyMap<string, Pick<ActivityContract, "secret">>;
|
|
227
|
+
},
|
|
228
|
+
): { checkpoint: GateCheckpoint } | { blocked: string } {
|
|
229
|
+
const secret = checkpointBlocked(config, gate, input.contracts);
|
|
230
|
+
if (secret) return { blocked: secret };
|
|
231
|
+
if (input.commit === undefined) return { blocked: "the commit this run is at could not be read, and a resumed run must be at the same one" };
|
|
232
|
+
if (!isCheckpointRunId(input.runId)) return { blocked: `the run id "${input.runId}" can't name a checkpoint file` };
|
|
233
|
+
const stored = checkpointOutputs(input.outputs, gate.stored);
|
|
234
|
+
if ("unstorable" in stored) return { blocked: `step "${stored.unstorable}"'s output can't be stored as JSON` };
|
|
235
|
+
return {
|
|
236
|
+
checkpoint: {
|
|
237
|
+
version: 1,
|
|
238
|
+
op: config.name,
|
|
239
|
+
gate: gate.name,
|
|
240
|
+
runId: input.runId,
|
|
241
|
+
commit: input.commit,
|
|
242
|
+
...(input.environment !== undefined ? { environment: input.environment } : {}),
|
|
243
|
+
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
244
|
+
outputs: stored.outputs,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** What {@link decideGateResume} found. */
|
|
250
|
+
export type GateResumeDecision =
|
|
251
|
+
| { kind: "none" }
|
|
252
|
+
| { kind: "refused"; gate: string; reason: string }
|
|
253
|
+
| { kind: "resume"; gate: ResumeGate; checkpoint: GateCheckpoint; digest: string; pending: PendingGateRecord };
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Decide whether this run resumes, and at which gate. The last resume gate
|
|
257
|
+
* whose standing pending fact carries a checkpoint, and which an approval
|
|
258
|
+
* recorded since then copied, is the candidate. Approved gates are tried
|
|
259
|
+
* from the last one back, so a run that resumed at one gate and stopped at a
|
|
260
|
+
* later one resumes at the later one next time.
|
|
261
|
+
*
|
|
262
|
+
* Reads the gate ledger and the checkpoint file and writes nothing. A gate
|
|
263
|
+
* with no checkpoint, or none an approval copied, is not a candidate: the run
|
|
264
|
+
* starts at the first phase as before, and says nothing.
|
|
265
|
+
*/
|
|
266
|
+
export async function decideGateResume(
|
|
267
|
+
port: GateLedgerPort,
|
|
268
|
+
config: Pick<OpConfig, "name" | "phases" | "onFailure" | "workLease">,
|
|
269
|
+
input: { now: string; commit: string | undefined; environment?: string },
|
|
270
|
+
): Promise<GateResumeDecision> {
|
|
271
|
+
const gates = resumeGatesOf(config);
|
|
272
|
+
if (gates.length === 0) return { kind: "none" };
|
|
273
|
+
const ledger = await port.read(config.name);
|
|
274
|
+
const spent = port.readApplied ? await port.readApplied(config.name) : [];
|
|
275
|
+
const at = (iso: string) => new Date(iso).getTime();
|
|
276
|
+
|
|
277
|
+
for (const gate of [...gates].reverse()) {
|
|
278
|
+
const standing = latestPendingGate(ledger.pending, gate.name);
|
|
279
|
+
const digest = standing?.checkpointDigest;
|
|
280
|
+
if (!standing || digest === undefined) continue;
|
|
281
|
+
// #3949: an approval carried from an alias never saw this gate's
|
|
282
|
+
// checkpoint, so it doesn't let the run resume. The run starts at the
|
|
283
|
+
// first phase and the carried approval passes the gate when it gets there.
|
|
284
|
+
const approved = ledger.resolutions.some(
|
|
285
|
+
(r) =>
|
|
286
|
+
r.gate === gate.name && r.carriedFrom === undefined && r.checkpointDigest === digest && at(r.timestamp) >= at(standing.timestamp) &&
|
|
287
|
+
samePlanDigest(r.planDigest, standing.planDigest) && !isApprovalSpent(spent, r),
|
|
288
|
+
);
|
|
289
|
+
if (!approved) continue;
|
|
290
|
+
|
|
291
|
+
const refuse = (reason: string): GateResumeDecision => ({ kind: "refused", gate: gate.name, reason });
|
|
292
|
+
if (isPendingGateExpired(standing, input.now)) return refuse(`its pending fact expired at ${standing.expiresAt}`);
|
|
293
|
+
if (!standing.runId) return refuse("its pending fact names no run");
|
|
294
|
+
const { dir, file } = gateCheckpointPath(config.name, standing.runId);
|
|
295
|
+
const text = port.readCheckpoint ? await port.readCheckpoint(config.name, standing.runId) : null;
|
|
296
|
+
if (text === null) return refuse(`the checkpoint ${dir}/${file} is not on the ledger`);
|
|
297
|
+
const checkpoint = parseGateCheckpoint(text);
|
|
298
|
+
if (checkpoint === null) return refuse(`${dir}/${file} is not a checkpoint`);
|
|
299
|
+
let actual: string;
|
|
300
|
+
try {
|
|
301
|
+
actual = gateCheckpointDigest(checkpoint);
|
|
302
|
+
} catch {
|
|
303
|
+
return refuse(`${dir}/${file} is not a checkpoint`);
|
|
304
|
+
}
|
|
305
|
+
if (actual !== digest) return refuse(`${dir}/${file} does not match the digest the approver saw (${digest}, the file is ${actual})`);
|
|
306
|
+
if (checkpoint.op !== config.name || checkpoint.gate !== gate.name || checkpoint.runId !== standing.runId) {
|
|
307
|
+
return refuse(`${dir}/${file} is for ${checkpoint.op} / ${checkpoint.gate}, run ${checkpoint.runId}`);
|
|
308
|
+
}
|
|
309
|
+
if (!samePlanDigest(checkpoint.planDigest, standing.planDigest)) return refuse(`${dir}/${file} names another plan than the pending fact`);
|
|
310
|
+
if (input.commit === undefined) return refuse("the commit this run is at could not be read");
|
|
311
|
+
if (input.commit !== checkpoint.commit) {
|
|
312
|
+
return refuse(`the run that stopped there was at commit ${checkpoint.commit.slice(0, 12)}, and this one is at ${input.commit.slice(0, 12)}`);
|
|
313
|
+
}
|
|
314
|
+
if (input.environment !== checkpoint.environment) {
|
|
315
|
+
return refuse(`the run that stopped there was for environment ${checkpoint.environment ?? "(none)"}, and this one is for ${input.environment ?? "(none)"}`);
|
|
316
|
+
}
|
|
317
|
+
const missing = gate.stored.find((id) => !(id in checkpoint.outputs));
|
|
318
|
+
if (missing !== undefined) return refuse(`${dir}/${file} holds no output for step "${missing}"`);
|
|
319
|
+
return { kind: "resume", gate, checkpoint, digest, pending: standing };
|
|
320
|
+
}
|
|
321
|
+
return { kind: "none" };
|
|
322
|
+
}
|