@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,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
|
+
}
|
package/src/op/gate.ts
CHANGED
|
@@ -55,6 +55,8 @@ import { pushLifecycleReport, readBlobBySha, readBlobFromPath, readPathSha, requ
|
|
|
55
55
|
import { parseDuration } from "./duration";
|
|
56
56
|
import { resolveGateRunLocator } from "./gate-run-locator";
|
|
57
57
|
import { refuseGateWithoutRecording } from "../lifecycle/no-record";
|
|
58
|
+
import { gateCheckpointDigest, gateCheckpointText, readGateCheckpointText, writeGateCheckpoint, type GateCheckpoint } from "../lifecycle/gate-checkpoint";
|
|
59
|
+
import type { ResolvedGateAlias } from "./gate-alias";
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
62
|
* What appending a pending fact learned about reaching the remote.
|
|
@@ -100,6 +102,15 @@ export interface GateLedgerPort {
|
|
|
100
102
|
readApplied?(op: string): Promise<AppliedGateRecord[]>;
|
|
101
103
|
/** Record that a wave is about to apply under an approval (#3784), which spends it. */
|
|
102
104
|
appendApplied?(record: AppliedGateRecord): Promise<{ pushed: boolean; pushWarning?: string }>;
|
|
105
|
+
/**
|
|
106
|
+
* Store the checkpoint of a run that stops at a gate that resumes (#3947),
|
|
107
|
+
* at `_gates/<op>/checkpoints/<runId>.json`. Written before the pending
|
|
108
|
+
* fact that names its digest, and pushed with it. Absent on a port that
|
|
109
|
+
* keeps none, and the gate then doesn't resume.
|
|
110
|
+
*/
|
|
111
|
+
writeCheckpoint?(checkpoint: GateCheckpoint): Promise<void>;
|
|
112
|
+
/** The text of a run's checkpoint, or null when there is none (#3947). */
|
|
113
|
+
readCheckpoint?(op: string, runId: string): Promise<string | null>;
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
/** A workspace's rule for which approvals of a gate count (#3163): a signed approval, from a class when it names one. */
|
|
@@ -148,6 +159,13 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
|
|
|
148
159
|
const { record } = await appendPendingGate(input, opts);
|
|
149
160
|
return { record, ...(await pushLifecycleReport(opts)) };
|
|
150
161
|
},
|
|
162
|
+
async writeCheckpoint(checkpoint) {
|
|
163
|
+
await writeGateCheckpoint(checkpoint, opts);
|
|
164
|
+
},
|
|
165
|
+
async readCheckpoint(op, runId) {
|
|
166
|
+
await requireLifecycleLedger(opts);
|
|
167
|
+
return readGateCheckpointText(op, runId, opts);
|
|
168
|
+
},
|
|
151
169
|
async readApplied(op) {
|
|
152
170
|
await requireLifecycleLedger(opts);
|
|
153
171
|
const { dir, file } = appliedGatePath(op);
|
|
@@ -181,16 +199,30 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
|
|
|
181
199
|
/** A gate ledger held in memory — what a test, or a caller that has already read the ledger, hands {@link evaluateGate}. */
|
|
182
200
|
export function memoryGateLedgerPort(
|
|
183
201
|
seed: { resolutions?: GateResolutionRecord[]; pending?: PendingGateRecord[]; applied?: AppliedGateRecord[] } = {},
|
|
184
|
-
): GateLedgerPort & {
|
|
202
|
+
): GateLedgerPort & {
|
|
203
|
+
appended: PendingGateRecord[];
|
|
204
|
+
resolved: GateResolutionRecord[];
|
|
205
|
+
applied: AppliedGateRecord[];
|
|
206
|
+
/** Checkpoint texts by `<op>/<runId>` (#3947). */
|
|
207
|
+
checkpoints: Map<string, string>;
|
|
208
|
+
} {
|
|
185
209
|
const resolutions = [...(seed.resolutions ?? [])];
|
|
186
210
|
const pending = [...(seed.pending ?? [])];
|
|
187
211
|
const appended: PendingGateRecord[] = [];
|
|
188
212
|
const resolved: GateResolutionRecord[] = [];
|
|
189
213
|
const applied: AppliedGateRecord[] = [...(seed.applied ?? [])];
|
|
214
|
+
const checkpoints = new Map<string, string>();
|
|
190
215
|
return {
|
|
191
216
|
appended,
|
|
192
217
|
resolved,
|
|
193
218
|
applied,
|
|
219
|
+
checkpoints,
|
|
220
|
+
async writeCheckpoint(checkpoint) {
|
|
221
|
+
checkpoints.set(`${checkpoint.op}/${checkpoint.runId}`, gateCheckpointText(checkpoint));
|
|
222
|
+
},
|
|
223
|
+
async readCheckpoint(op, runId) {
|
|
224
|
+
return checkpoints.get(`${op}/${runId}`) ?? null;
|
|
225
|
+
},
|
|
194
226
|
async readApplied(op) {
|
|
195
227
|
return applied.filter((a) => a.op === op);
|
|
196
228
|
},
|
|
@@ -271,6 +303,24 @@ export interface GateCheckInput {
|
|
|
271
303
|
fields?: Record<string, unknown>;
|
|
272
304
|
/** The pending fact it records carries `neverOverMcp` (#3821, chant#3485). */
|
|
273
305
|
neverOverMcp?: boolean;
|
|
306
|
+
/**
|
|
307
|
+
* The checkpoint of a run at a gate that resumes (#3947). Given, a pending
|
|
308
|
+
* fact this records first stores it through the port's `writeCheckpoint`
|
|
309
|
+
* and carries its digest as `checkpointDigest`. A standing fact with no
|
|
310
|
+
* checkpoint is recorded again, so the gate gets one.
|
|
311
|
+
*/
|
|
312
|
+
checkpoint?: GateCheckpoint;
|
|
313
|
+
/**
|
|
314
|
+
* The gate's old names (#3949), each with its Op filled in
|
|
315
|
+
* (`resolveGateAliases` in `./gate-alias.ts`). Read only on a gate bound
|
|
316
|
+
* to a plan. A person's approval recorded under one counts toward this
|
|
317
|
+
* gate when it answers that name's standing pending fact, the fact hasn't
|
|
318
|
+
* expired, it is for `planDigest` and `environment`, neither name has
|
|
319
|
+
* spent it, and it passes this gate's approval rule. An approval this
|
|
320
|
+
* counts is written again under this gate (`carriedFrom`), and a line
|
|
321
|
+
* carried earlier counts only while its alias is still declared.
|
|
322
|
+
*/
|
|
323
|
+
aliases?: readonly ResolvedGateAlias[];
|
|
274
324
|
}
|
|
275
325
|
|
|
276
326
|
/**
|
|
@@ -335,11 +385,14 @@ export type GateCheck =
|
|
|
335
385
|
/**
|
|
336
386
|
* Set on a gate that declares `approval` (#2508). `"quorum"` when enough
|
|
337
387
|
* human approvals were recorded, `"policy"` when an `enforce`-mode permit
|
|
338
|
-
* passed it on its own.
|
|
388
|
+
* passed it on its own. `"alias"` (#3949) when an approval recorded
|
|
389
|
+
* under one of the gate's aliases counted, on any gate.
|
|
339
390
|
*/
|
|
340
|
-
via?: "quorum" | "policy";
|
|
391
|
+
via?: "quorum" | "policy" | "alias";
|
|
341
392
|
/** Every approval that counted toward the quorum. Set with `via`. */
|
|
342
393
|
approvals?: GateResolutionRecord[];
|
|
394
|
+
/** The approvals this run carried from an alias and wrote under the gate's name (#3949). Set when `via` is `"alias"`. */
|
|
395
|
+
carried?: GateResolutionRecord[];
|
|
343
396
|
}
|
|
344
397
|
| {
|
|
345
398
|
satisfied: false;
|
|
@@ -500,6 +553,103 @@ export function humanQuorumSince(pending: readonly PendingGateRecord[], standing
|
|
|
500
553
|
return anchor.timestamp;
|
|
501
554
|
}
|
|
502
555
|
|
|
556
|
+
/**
|
|
557
|
+
* The approvals that answer `standing` (#3949): of its gate, environment and
|
|
558
|
+
* plan, recorded since it, or on a gate with an `approval` block since the
|
|
559
|
+
* human quorum's anchor ({@link humanQuorumSince}). The rule
|
|
560
|
+
* `resolutionAnswering` (`./gate-resume.ts`) decides by, without the tally:
|
|
561
|
+
* each one counts on its own toward a gate an alias names.
|
|
562
|
+
*/
|
|
563
|
+
export function approvalsAnswering(
|
|
564
|
+
resolutions: readonly GateResolutionRecord[],
|
|
565
|
+
standing: PendingGateRecord,
|
|
566
|
+
pending: readonly PendingGateRecord[] = [standing],
|
|
567
|
+
): GateResolutionRecord[] {
|
|
568
|
+
const since = new Date(standing.approval ? humanQuorumSince(pending, standing) : standing.timestamp).getTime();
|
|
569
|
+
return resolutions.filter(
|
|
570
|
+
(r) =>
|
|
571
|
+
r.gate === standing.gate && r.environment === standing.environment && samePlanDigest(r.planDigest, standing.planDigest) &&
|
|
572
|
+
new Date(r.timestamp).getTime() >= since,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The line a run writes for an approval it carries from an alias (#3949):
|
|
578
|
+
* the approval under `op`/`gate` at `now`, citing the line it came from.
|
|
579
|
+
* The approver, plan, environment, relay and seal are the original's. A
|
|
580
|
+
* policy decision is left behind, since it was evaluated against another
|
|
581
|
+
* gate, and so is a checkpoint digest, which moves onto `carriedFrom`: the
|
|
582
|
+
* approver saw that checkpoint, not one this gate stores, so a carried
|
|
583
|
+
* approval never lets a run resume.
|
|
584
|
+
*/
|
|
585
|
+
export function carriedApproval(r: GateResolutionRecord, op: string, gate: string, now: string): GateResolutionInput {
|
|
586
|
+
return {
|
|
587
|
+
op,
|
|
588
|
+
gate,
|
|
589
|
+
resolvedBy: r.resolvedBy,
|
|
590
|
+
...(r.relayedBy !== undefined ? { relayedBy: r.relayedBy } : {}),
|
|
591
|
+
timestamp: now,
|
|
592
|
+
...(r.note ? { note: r.note } : {}),
|
|
593
|
+
...(r.url ? { url: r.url } : {}),
|
|
594
|
+
...(r.planDigest !== undefined ? { planDigest: r.planDigest } : {}),
|
|
595
|
+
...(r.environment !== undefined ? { environment: r.environment } : {}),
|
|
596
|
+
...(r.origin ? { origin: r.origin } : {}),
|
|
597
|
+
...(r.sameOriginOverride ? { sameOriginOverride: true } : {}),
|
|
598
|
+
approver: approverOf(r),
|
|
599
|
+
...(r.seal ? { seal: r.seal } : {}),
|
|
600
|
+
carriedFrom: {
|
|
601
|
+
op: r.op,
|
|
602
|
+
gate: r.gate,
|
|
603
|
+
timestamp: r.timestamp,
|
|
604
|
+
...(r.checkpointDigest !== undefined ? { checkpointDigest: r.checkpointDigest } : {}),
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The approvals recorded under `input.aliases` that may count toward this
|
|
611
|
+
* gate (#3949), oldest first. Each answers its alias's standing pending
|
|
612
|
+
* fact, which hasn't expired and was recorded for this plan and
|
|
613
|
+
* environment. Each is a person's, is spent under neither name, hasn't been
|
|
614
|
+
* carried already (`ledger` is this gate's own, so a carried line that was
|
|
615
|
+
* spent keeps its original from being carried again), and passes `rule`,
|
|
616
|
+
* whose seal check reads the names the approval was signed under. One the
|
|
617
|
+
* rule refuses is added to `refused`.
|
|
618
|
+
*/
|
|
619
|
+
async function aliasApprovals(
|
|
620
|
+
port: GateLedgerPort,
|
|
621
|
+
input: GateCheckInput & { aliases: readonly ResolvedGateAlias[] },
|
|
622
|
+
ledger: readonly GateResolutionRecord[],
|
|
623
|
+
rule: GateApprovalRule | null,
|
|
624
|
+
now: string,
|
|
625
|
+
refused: GateRefusedApproval[],
|
|
626
|
+
): Promise<GateResolutionRecord[]> {
|
|
627
|
+
const at = (iso: string) => new Date(iso).getTime();
|
|
628
|
+
const carried = ledger.filter((r) => r.gate === input.gate && r.carriedFrom !== undefined);
|
|
629
|
+
const wasCarried = (r: GateResolutionRecord) =>
|
|
630
|
+
carried.some((c) => c.carriedFrom!.op === r.op && c.carriedFrom!.gate === r.gate && at(c.carriedFrom!.timestamp) === at(r.timestamp) && c.resolvedBy === r.resolvedBy);
|
|
631
|
+
const ledgers = new Map<string, Awaited<ReturnType<GateLedgerPort["read"]>>>();
|
|
632
|
+
const out: GateResolutionRecord[] = [];
|
|
633
|
+
for (const alias of input.aliases) {
|
|
634
|
+
let other = ledgers.get(alias.op);
|
|
635
|
+
if (!other) ledgers.set(alias.op, (other = await port.read(alias.op)));
|
|
636
|
+
const pending = other.pending.filter((p) => p.environment === input.environment);
|
|
637
|
+
const standing = latestPendingGate(pending, alias.gate);
|
|
638
|
+
if (!standing || isPendingGateExpired(standing, now) || !samePlanDigest(standing.planDigest, input.planDigest)) continue;
|
|
639
|
+
const spent = alias.op === input.op && input.spent ? input.spent : ((await port.readApplied?.(alias.op)) ?? []);
|
|
640
|
+
for (const r of approvalsAnswering(other.resolutions, standing, pending)) {
|
|
641
|
+
if (r.carriedFrom !== undefined || approverOf(r).kind !== "human" || isApprovalSpent(spent, r) || wasCarried(r)) continue;
|
|
642
|
+
const reason = rule?.refuses(r) ?? null;
|
|
643
|
+
if (reason !== null) {
|
|
644
|
+
refused.push({ resolution: r, reason: `carried from ${alias.op}/${alias.gate}: ${reason}` });
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
out.push(r);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return out.sort((a, b) => at(a.timestamp) - at(b.timestamp));
|
|
651
|
+
}
|
|
652
|
+
|
|
503
653
|
/** Whether two resolved approval blocks are the same, so a standing pending fact still describes this gate. */
|
|
504
654
|
function sameApproval(a: ResolvedGateApproval | undefined, b: ResolvedGateApproval | undefined): boolean {
|
|
505
655
|
return JSON.stringify(a ?? null, sortedJsonReplacer) === JSON.stringify(b ?? null, sortedJsonReplacer);
|
|
@@ -546,6 +696,9 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
546
696
|
const rule = (await port.approvalRule?.(input.gate)) ?? null;
|
|
547
697
|
const standing = latestPendingGate(pending, input.gate);
|
|
548
698
|
const since = standing?.timestamp ?? EPOCH;
|
|
699
|
+
// #3949: aliases admit only approvals of this run's plan, and carrying one writes a line.
|
|
700
|
+
const aliases = input.planDigest !== undefined && port.appendResolution ? (input.aliases ?? []) : [];
|
|
701
|
+
const aliased = (from: { op: string; gate: string }) => aliases.some((a) => a.op === from.op && a.gate === from.gate);
|
|
549
702
|
// #3841: an approval of this plan the rule refuses is named, so a wait says why.
|
|
550
703
|
const refused: GateRefusedApproval[] = [];
|
|
551
704
|
const resolutions = (
|
|
@@ -553,6 +706,8 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
553
706
|
? ledger.resolutions
|
|
554
707
|
: ledger.resolutions.filter((r) => r.environment === undefined || r.environment === env)
|
|
555
708
|
)
|
|
709
|
+
// #3949: a carried approval counts only while the gate still names its alias.
|
|
710
|
+
.filter((r) => r.gate !== input.gate || r.carriedFrom === undefined || aliased(r.carriedFrom))
|
|
556
711
|
.filter((r) => input.spent === undefined || r.gate !== input.gate || !isApprovalSpent(input.spent, r))
|
|
557
712
|
.filter((r) => {
|
|
558
713
|
if (rule === null || r.gate !== input.gate) return true;
|
|
@@ -561,8 +716,16 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
561
716
|
if (samePlanDigest(r.planDigest, input.planDigest) && new Date(r.timestamp).getTime() >= new Date(since).getTime()) refused.push({ resolution: r, reason });
|
|
562
717
|
return false;
|
|
563
718
|
});
|
|
719
|
+
const fromAliases = aliases.length > 0 ? await aliasApprovals(port, { ...input, aliases }, ledger.resolutions, rule, now, refused) : [];
|
|
564
720
|
refused.sort((a, b) => new Date(a.resolution.timestamp).getTime() - new Date(b.resolution.timestamp).getTime());
|
|
565
721
|
|
|
722
|
+
// #3949: write the approvals that counted from an alias under this gate.
|
|
723
|
+
const carry = async (records: readonly GateResolutionRecord[]): Promise<GateResolutionRecord[]> => {
|
|
724
|
+
const written: GateResolutionRecord[] = [];
|
|
725
|
+
for (const r of records) written.push((await port.appendResolution!(carriedApproval(r, input.op, input.gate, now))).record);
|
|
726
|
+
return written;
|
|
727
|
+
};
|
|
728
|
+
|
|
566
729
|
let mismatched: GateResolutionRecord | undefined;
|
|
567
730
|
let quorum: GateQuorumProgress | undefined;
|
|
568
731
|
if (input.approval) {
|
|
@@ -587,9 +750,28 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
587
750
|
}
|
|
588
751
|
mismatched = tally.mismatched;
|
|
589
752
|
quorum = { approvers: tally.counted.map((r) => r.resolvedBy), need: tally.need };
|
|
753
|
+
// #3949: old and new approvals add up toward one quorum, each approver once.
|
|
754
|
+
const counted = new Set(tally.counted.map((r) => r.resolvedBy));
|
|
755
|
+
const extra = fromAliases.filter((r) => !counted.has(r.resolvedBy));
|
|
756
|
+
if (extra.length > 0) {
|
|
757
|
+
const asCarried = new Map(extra.map((r) => [{ version: 1, ...carriedApproval(r, input.op, input.gate, now) } as GateResolutionRecord, r]));
|
|
758
|
+
const combined = tallyGateApprovals([...resolutions, ...asCarried.keys()], input.gate, since, input.planDigest, input.approval, humansSince);
|
|
759
|
+
if (combined.counted.length >= combined.need) {
|
|
760
|
+
const written = await carry(combined.counted.filter((r) => asCarried.has(r)).map((r) => asCarried.get(r)!));
|
|
761
|
+
const approvals = [...combined.counted.filter((r) => !asCarried.has(r)), ...written];
|
|
762
|
+
return { satisfied: true, resolution: written[written.length - 1]!, via: "alias", approvals, carried: written };
|
|
763
|
+
}
|
|
764
|
+
quorum = { approvers: combined.counted.map((r) => r.resolvedBy), need: combined.need };
|
|
765
|
+
}
|
|
590
766
|
} else {
|
|
591
767
|
const found = latestResolutionForPlan(resolutions, input.gate, since, input.planDigest);
|
|
592
768
|
if (found.resolution) return { satisfied: true, resolution: found.resolution };
|
|
769
|
+
// #3949: else the newest approval an alias admits passes it, written under this gate.
|
|
770
|
+
const newest = fromAliases[fromAliases.length - 1];
|
|
771
|
+
if (newest) {
|
|
772
|
+
const written = await carry([newest]);
|
|
773
|
+
return { satisfied: true, resolution: written[0]!, via: "alias", carried: written };
|
|
774
|
+
}
|
|
593
775
|
mismatched = found.mismatched;
|
|
594
776
|
}
|
|
595
777
|
|
|
@@ -614,13 +796,24 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
614
796
|
// The same holds for the approval block (#2508): a pending fact recorded
|
|
615
797
|
// under another policy version or another context would have `chant
|
|
616
798
|
// approve` evaluate the policy against something this run no longer has.
|
|
799
|
+
// #3947: and for the checkpoint, so a gate that resumes gets one. A fact
|
|
800
|
+
// that already carries a checkpoint stands: an approval copies that one.
|
|
617
801
|
if (
|
|
618
802
|
standing && !isPendingGateExpired(standing, now) && samePlanDigest(standing.planDigest, input.planDigest) &&
|
|
619
|
-
sameApproval(standing.approval, input.approval)
|
|
803
|
+
sameApproval(standing.approval, input.approval) &&
|
|
804
|
+
(input.checkpoint === undefined || port.writeCheckpoint === undefined || standing.checkpointDigest !== undefined)
|
|
620
805
|
) {
|
|
621
806
|
return { satisfied: false, pending: standing, recorded: false, ...asMismatch };
|
|
622
807
|
}
|
|
623
808
|
|
|
809
|
+
// The checkpoint is written before the fact that names it, so a fact never
|
|
810
|
+
// points at a file that isn't there.
|
|
811
|
+
let checkpointDigest: string | undefined;
|
|
812
|
+
if (input.checkpoint !== undefined && port.writeCheckpoint) {
|
|
813
|
+
checkpointDigest = gateCheckpointDigest(input.checkpoint);
|
|
814
|
+
await port.writeCheckpoint(input.checkpoint);
|
|
815
|
+
}
|
|
816
|
+
|
|
624
817
|
const url = resolveApprovalUrl();
|
|
625
818
|
// #3683: in CI, where this job runs, so an approval can start it again.
|
|
626
819
|
const resume = resolveGateRunLocator();
|
|
@@ -637,6 +830,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
637
830
|
...(url ? { url } : {}),
|
|
638
831
|
...(resume ? { resume } : {}),
|
|
639
832
|
...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
|
|
833
|
+
...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
|
|
640
834
|
...(env !== undefined ? { environment: env } : {}),
|
|
641
835
|
...(input.approval ? { approval: input.approval } : {}),
|
|
642
836
|
...(input.neverOverMcp ? { neverOverMcp: true as const } : {}),
|
package/src/op/index.ts
CHANGED
|
@@ -156,9 +156,12 @@ export type {
|
|
|
156
156
|
export { renderHuman, renderJson } from "./local-output";
|
|
157
157
|
export {
|
|
158
158
|
activityContract, isActivityContract, collectActivityContracts, validateActivitySteps, KNOWN_ACTIVITY_PROFILES,
|
|
159
|
-
pathExistsInSchema,
|
|
159
|
+
pathExistsInSchema, readsSecretOutput,
|
|
160
160
|
} from "./activity-contract";
|
|
161
161
|
export type { ActivityContract, ActivityContractIssue } from "./activity-contract";
|
|
162
|
+
export { validateGateResume, resumeGatesOf, decideGateResume } from "./gate-checkpoint";
|
|
163
|
+
export type { ResumeGate, GateResumeDecision } from "./gate-checkpoint";
|
|
164
|
+
export type { GateCheckpoint } from "../lifecycle/gate-checkpoint";
|
|
162
165
|
export { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs, validateStepOutputRefScope, makeOutProxy } from "./step-output-ref";
|
|
163
166
|
export type { StepOutputRef, WithStepRefs } from "./step-output-ref";
|
|
164
167
|
export type { NamedActivityStep } from "./builders";
|