@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
package/src/op/local-executor.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* ledger, so `chant approve <op> <gate>` followed by `chant run <op>` is the
|
|
15
15
|
* whole loop.
|
|
16
16
|
*
|
|
17
|
+
* A gate with `resume: true` (#3947) also stores a checkpoint of the step
|
|
18
|
+
* outputs the steps after it read, and the next run after the approval starts
|
|
19
|
+
* at the gate instead of the first phase. It re-runs the step that produced
|
|
20
|
+
* the gate's plan first, and starts at the first phase after all when the
|
|
21
|
+
* plan moved. See `./gate-checkpoint.ts`.
|
|
22
|
+
*
|
|
17
23
|
* The executor is deliberately decoupled from any lexicon: activity
|
|
18
24
|
* implementations and profiles are passed in (loaded dynamically by the CLI),
|
|
19
25
|
* so core never statically depends on one.
|
|
@@ -23,12 +29,15 @@ import { outcomeAttributesOf } from "./types";
|
|
|
23
29
|
import type { OpConfig, PhaseDefinition, ActivityStep, GateStep, EffectStep, StepDefinition } from "./types";
|
|
24
30
|
import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activity-registry";
|
|
25
31
|
import type { ReceiptReadResult } from "./receipt-store";
|
|
26
|
-
import { isStepOutputRef } from "./step-output-ref";
|
|
32
|
+
import { collectStepOutputRefs, isStepOutputRef } from "./step-output-ref";
|
|
33
|
+
import { canonicalJson } from "../effect-receipt";
|
|
34
|
+
import { contentDigest } from "../content-digest";
|
|
27
35
|
import { parseDuration } from "./duration";
|
|
28
36
|
import { stepLabel, withRunConsole, withStepConsole } from "./step-console";
|
|
29
37
|
import { stepTimeoutProblem } from "./activity-profiles";
|
|
30
38
|
import { describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
|
|
31
39
|
import { gateName } from "./gate-name";
|
|
40
|
+
import { resolveGateAliases } from "./gate-alias";
|
|
32
41
|
import { gatePointOf } from "./gate-point";
|
|
33
42
|
import { evaluatePointGate, workspaceGatePointAsker, type GatePointAsker } from "./gate-point-run";
|
|
34
43
|
import type { ResolvedGateApproval } from "./gate-approval";
|
|
@@ -45,6 +54,11 @@ import { RunWorkLease, LEASE_LOST, workLeaseProblems, workLeaseNeedsRunItem, typ
|
|
|
45
54
|
import { WORK_LEASE_STEP_ID } from "./types";
|
|
46
55
|
import { LiveRun, currentLiveRun, withLiveRun } from "./run-live";
|
|
47
56
|
import { runEnvOf } from "../lifecycle/run-ledger";
|
|
57
|
+
import { getHeadCommit } from "../lifecycle/git";
|
|
58
|
+
import { describePlanDigest, samePlanDigest } from "../lifecycle/plan-digest";
|
|
59
|
+
import type { GateCheckpoint } from "../lifecycle/gate-checkpoint";
|
|
60
|
+
import type { ActivityContract } from "./activity-contract";
|
|
61
|
+
import { buildGateCheckpoint, decideGateResume, resumeGatesOf, type GateResumeDecision, type ResumeGate } from "./gate-checkpoint";
|
|
48
62
|
|
|
49
63
|
export { parseDuration } from "./duration";
|
|
50
64
|
|
|
@@ -76,12 +90,17 @@ export interface StepRecord {
|
|
|
76
90
|
resolvedBy: string;
|
|
77
91
|
timestamp: string;
|
|
78
92
|
url?: string;
|
|
79
|
-
/**
|
|
80
|
-
|
|
93
|
+
/**
|
|
94
|
+
* On a gate with an `approval` block (#2508): whether a quorum or a policy permit passed it. `"point"` on a gate that asks a decision point (#3170).
|
|
95
|
+
* `"alias"` (#3949) when an approval recorded under one of the gate's aliases counted, on any gate.
|
|
96
|
+
*/
|
|
97
|
+
via?: "quorum" | "policy" | "point" | "alias";
|
|
81
98
|
/** On a gate with a quorum (#2508): every approver who counted toward it. */
|
|
82
99
|
approvers?: string[];
|
|
83
100
|
/** On a gate that asks a decision point (#3170): the answer record that passed it, which the gate ledger's resolution cites too. */
|
|
84
101
|
answer?: GateAnswerRef;
|
|
102
|
+
/** With `via: "alias"` (#3949): the approvals carried from an alias, each by the op, gate and time it was recorded under. */
|
|
103
|
+
carriedFrom?: Array<{ op: string; gate: string; resolvedBy: string; timestamp: string }>;
|
|
85
104
|
};
|
|
86
105
|
/**
|
|
87
106
|
* Why a step declined to proceed on something that is not a failure
|
|
@@ -103,6 +122,12 @@ export interface StepRecord {
|
|
|
103
122
|
* approve` takes. The step's status is `skipped` and the run's is `gated`.
|
|
104
123
|
*/
|
|
105
124
|
gate?: { op: string; gate: string };
|
|
125
|
+
/**
|
|
126
|
+
* Set on a step this run did not execute because it resumed at a gate
|
|
127
|
+
* (#3947): the run whose checkpoint supplied the step's output, and the
|
|
128
|
+
* gate. The step's status is `skipped`.
|
|
129
|
+
*/
|
|
130
|
+
resumed?: { run: string; gate: string };
|
|
106
131
|
}
|
|
107
132
|
|
|
108
133
|
export interface OpRunResult {
|
|
@@ -149,6 +174,18 @@ export interface OpRunResult {
|
|
|
149
174
|
* was released and with which outcome, or why it was lost.
|
|
150
175
|
*/
|
|
151
176
|
workLease?: WorkLeaseRunResult;
|
|
177
|
+
/**
|
|
178
|
+
* Set when this run resumed at an approved gate (#3947): the gate, the run
|
|
179
|
+
* whose checkpoint it carried on from, and that checkpoint's digest.
|
|
180
|
+
*/
|
|
181
|
+
resumed?: { gate: string; run: string; checkpoint: string };
|
|
182
|
+
/**
|
|
183
|
+
* Why a gate that resumes did not (#3947), one line each: an approved gate
|
|
184
|
+
* whose checkpoint failed a check, so the run started at the first phase,
|
|
185
|
+
* or a gate this run stopped at that stored no checkpoint. Absent when
|
|
186
|
+
* there is nothing to say.
|
|
187
|
+
*/
|
|
188
|
+
resumeNotes?: string[];
|
|
152
189
|
}
|
|
153
190
|
|
|
154
191
|
// ── Errors ────────────────────────────────────────────────────────────────��─
|
|
@@ -278,6 +315,48 @@ function resolveStepOutputRefs(value: unknown, resultsById: ReadonlyMap<string,
|
|
|
278
315
|
return value;
|
|
279
316
|
}
|
|
280
317
|
|
|
318
|
+
/** The activity steps a step runs: itself, or an effect's nested ones. */
|
|
319
|
+
function activitiesOf(step: ActivityStep | EffectStep): ActivityStep[] {
|
|
320
|
+
return isEffect(step) ? step.steps.filter(isActivity) : [step];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The first step a compensation step references that has not completed in
|
|
325
|
+
* this run (#3948 decision A6), or `undefined` when every one did. A failure
|
|
326
|
+
* can stop the run before the step that a rollback reads, and calling the
|
|
327
|
+
* rollback with `undefined` in place of that value would undo the wrong
|
|
328
|
+
* thing, or nothing, while reporting `ok`.
|
|
329
|
+
*/
|
|
330
|
+
function unfinishedSource(step: ActivityStep | EffectStep, resultsById: ReadonlyMap<string, unknown>): string | undefined {
|
|
331
|
+
for (const activity of activitiesOf(step)) {
|
|
332
|
+
const missing = collectStepOutputRefs(activity.args).find((ref) => !resultsById.has(ref.step));
|
|
333
|
+
if (missing) return missing.step;
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The skipped record of a compensation step whose source did not complete. */
|
|
339
|
+
function unfinishedSourceRecord(step: ActivityStep | EffectStep, phaseName: string, source: string): StepRecord {
|
|
340
|
+
const record = isEffect(step) ? skippedRecord(phaseName, `effect:${step.receipt.name}`) : skippedRecord(phaseName, step.fn, step.args);
|
|
341
|
+
return { ...record, refusal: `step "${source}" did not complete in this run, so this compensation step, which reads its output, was skipped` };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The digest of the step outputs an effect's nested steps read (#3948), or
|
|
346
|
+
* `undefined` when they read none. The receipt store folds it into the
|
|
347
|
+
* expectation, so a changed value re-fires the effect instead of the receipt
|
|
348
|
+
* matching on the declared inputs alone. An existence receipt says only that
|
|
349
|
+
* the effect ran once, so it takes none.
|
|
350
|
+
*/
|
|
351
|
+
function effectOutputsDigest(step: EffectStep, resultsById: ReadonlyMap<string, unknown>): string | undefined {
|
|
352
|
+
if (step.receipt.flavor === "existence") return undefined;
|
|
353
|
+
const read = activitiesOf(step).flatMap((a) =>
|
|
354
|
+
collectStepOutputRefs(a.args).map((ref) => ({ step: ref.step, path: ref.path ?? "", value: resolveStepOutputRefs(ref, resultsById) })),
|
|
355
|
+
);
|
|
356
|
+
if (read.length === 0) return undefined;
|
|
357
|
+
return contentDigest(canonicalJson(read));
|
|
358
|
+
}
|
|
359
|
+
|
|
281
360
|
/**
|
|
282
361
|
* Find the first `policyGate` step anywhere in the Op (phases + `onFailure`,
|
|
283
362
|
* including steps nested inside effect steps), if any.
|
|
@@ -464,13 +543,14 @@ async function runStep(
|
|
|
464
543
|
/** The step data the executor synthesizes to read a receipt through the store
|
|
465
544
|
* activities (`receiptRead`/`receiptWrite` — provided by the receipt row's
|
|
466
545
|
* lexicon, #1835, or a mock store in tests via `receiptActivities`). */
|
|
467
|
-
function receiptReadStep(step: EffectStep): ActivityStep {
|
|
546
|
+
function receiptReadStep(step: EffectStep, outputs?: string): ActivityStep {
|
|
468
547
|
return {
|
|
469
548
|
kind: "activity",
|
|
470
549
|
fn: "receiptRead",
|
|
471
550
|
args: {
|
|
472
551
|
receipt: step.receipt,
|
|
473
552
|
...(step.expectation !== undefined ? { expectation: step.expectation } : {}),
|
|
553
|
+
...(outputs !== undefined ? { outputs } : {}),
|
|
474
554
|
},
|
|
475
555
|
profile: "fastIdempotent",
|
|
476
556
|
outcomeAttribute: { name: "EffectApplied", from: "applied" },
|
|
@@ -496,10 +576,20 @@ interface GateContext {
|
|
|
496
576
|
passed?: PassedGate[];
|
|
497
577
|
/** The run's hold on its work item's lease (#2748). Absent in compensation phases, which run whatever the lease did. */
|
|
498
578
|
work?: RunWorkLease;
|
|
579
|
+
/** Set in `onFailure` phases (#3948): a step whose referenced step did not complete is skipped. */
|
|
580
|
+
compensating?: boolean;
|
|
499
581
|
/** How a gate that names a decision point asks it (#3170). */
|
|
500
582
|
points?: GatePointAsker;
|
|
501
583
|
/** The environment the run was started for, a point gate's `gate.env` input. */
|
|
502
584
|
env?: string;
|
|
585
|
+
/** The Op's gates that resume (#3947), and what a checkpoint at one needs. Absent when it has none. */
|
|
586
|
+
resume?: {
|
|
587
|
+
config: OpConfig;
|
|
588
|
+
gates: Map<GateStep, ResumeGate>;
|
|
589
|
+
commit: string | undefined;
|
|
590
|
+
contracts?: ReadonlyMap<string, ActivityContract>;
|
|
591
|
+
note: (line: string) => void;
|
|
592
|
+
};
|
|
503
593
|
}
|
|
504
594
|
|
|
505
595
|
/**
|
|
@@ -632,6 +722,22 @@ async function runGateStep(
|
|
|
632
722
|
const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
|
|
633
723
|
const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
|
|
634
724
|
const approval = resolveGateApproval(step, resultsById);
|
|
725
|
+
// #3947: a gate that resumes stores the outputs the steps after it read.
|
|
726
|
+
const resumeAt = gates.resume?.gates.get(step);
|
|
727
|
+
let checkpoint: GateCheckpoint | undefined;
|
|
728
|
+
let blocked: string | undefined;
|
|
729
|
+
if (resumeAt && gates.resume) {
|
|
730
|
+
const built = buildGateCheckpoint(gates.resume.config, resumeAt, {
|
|
731
|
+
runId: gates.runId ?? "",
|
|
732
|
+
commit: gates.resume.commit,
|
|
733
|
+
...(gates.env !== undefined ? { environment: gates.env } : {}),
|
|
734
|
+
...(planDigest !== undefined ? { planDigest } : {}),
|
|
735
|
+
outputs: resultsById,
|
|
736
|
+
...(gates.resume.contracts ? { contracts: gates.resume.contracts } : {}),
|
|
737
|
+
});
|
|
738
|
+
if ("blocked" in built) blocked = built.blocked;
|
|
739
|
+
else checkpoint = built.checkpoint;
|
|
740
|
+
}
|
|
635
741
|
let check: GateCheck;
|
|
636
742
|
try {
|
|
637
743
|
check = await evaluateGate(gates.port, {
|
|
@@ -643,6 +749,8 @@ async function runGateStep(
|
|
|
643
749
|
...(planDigest !== undefined ? { planDigest } : {}),
|
|
644
750
|
...(approval ? { approval } : {}),
|
|
645
751
|
...(gates.now ? { now: gates.now } : {}),
|
|
752
|
+
...(checkpoint ? { checkpoint } : {}),
|
|
753
|
+
...(step.aliases?.length ? { aliases: resolveGateAliases(step, gates.op) } : {}),
|
|
646
754
|
});
|
|
647
755
|
} catch (err) {
|
|
648
756
|
// Deciding a gate reads and writes the `chant/lifecycle` branch, so it
|
|
@@ -674,6 +782,9 @@ async function runGateStep(
|
|
|
674
782
|
...(resolution.url ? { url: resolution.url } : {}),
|
|
675
783
|
...(check.via ? { via: check.via } : {}),
|
|
676
784
|
...(check.approvals ? { approvers: check.approvals.map((r) => r.resolvedBy) } : {}),
|
|
785
|
+
...(check.carried?.length
|
|
786
|
+
? { carriedFrom: check.carried.map((r) => ({ op: r.carriedFrom!.op, gate: r.carriedFrom!.gate, resolvedBy: r.resolvedBy, timestamp: r.carriedFrom!.timestamp })) }
|
|
787
|
+
: {}),
|
|
677
788
|
};
|
|
678
789
|
// #2522: a later step reads this gate's approval from the run context
|
|
679
790
|
// rather than re-tallying the ledger, so it sees the decision this run made.
|
|
@@ -690,6 +801,7 @@ async function runGateStep(
|
|
|
690
801
|
};
|
|
691
802
|
}
|
|
692
803
|
|
|
804
|
+
if (blocked) gates.resume?.note(`gate "${gateName(step)}" stored no checkpoint, so it won't resume: ${blocked}`);
|
|
693
805
|
const refusal = check.mismatch
|
|
694
806
|
? { refusal: describeGateMismatch(gates.op, gateName(step), check.mismatch, await gateIsSealed(gateName(step))) }
|
|
695
807
|
: {};
|
|
@@ -790,7 +902,15 @@ async function runEffectStep(
|
|
|
790
902
|
}> {
|
|
791
903
|
const records: StepRecord[] = [];
|
|
792
904
|
|
|
793
|
-
|
|
905
|
+
let outputs: string | undefined;
|
|
906
|
+
try {
|
|
907
|
+
outputs = effectOutputsDigest(step, resultsById);
|
|
908
|
+
} catch (err) {
|
|
909
|
+
// A resolved value JSON cannot hold (canonicalJson refuses it) would be a wrong expectation.
|
|
910
|
+
pushRecord(records, gates, { phase: phaseName, fn: `effect:${step.receipt.name}`, args: {}, status: "fail", durationMs: 0, error: errMessage(err) });
|
|
911
|
+
return { records, failed: true };
|
|
912
|
+
}
|
|
913
|
+
const read = await runStep(receiptReadStep(step, outputs), phaseName, activities, profiles, resultsById, signal);
|
|
794
914
|
pushRecord(records, gates, read.record);
|
|
795
915
|
if (read.record.status === "fail") return { records, failed: true };
|
|
796
916
|
|
|
@@ -897,6 +1017,8 @@ async function runEffectStep(
|
|
|
897
1017
|
/**
|
|
898
1018
|
* Run a phase. Throws `PhaseFailure` (with the records so far) if any step
|
|
899
1019
|
* fails, or `GateStop` if a `gate` step in it is still pending approval.
|
|
1020
|
+
* `from` starts a sequential phase at that step, for a run that resumes at a
|
|
1021
|
+
* gate in it (#3947); the steps before it are the caller's to record.
|
|
900
1022
|
*/
|
|
901
1023
|
async function runPhase(
|
|
902
1024
|
phase: PhaseDefinition,
|
|
@@ -905,6 +1027,7 @@ async function runPhase(
|
|
|
905
1027
|
resultsById: Map<string, unknown>,
|
|
906
1028
|
gates: GateContext,
|
|
907
1029
|
signal?: AbortSignal,
|
|
1030
|
+
from = 0,
|
|
908
1031
|
): Promise<StepRecord[]> {
|
|
909
1032
|
if (phase.parallel) {
|
|
910
1033
|
const eff = phase.steps.find(isEffect);
|
|
@@ -950,7 +1073,10 @@ async function runPhase(
|
|
|
950
1073
|
}
|
|
951
1074
|
}
|
|
952
1075
|
const steps = phase.steps.filter(isActivity);
|
|
953
|
-
const settled = await Promise.all(steps.map((s) =>
|
|
1076
|
+
const settled = await Promise.all(steps.map((s) => {
|
|
1077
|
+
const source = gates.compensating ? unfinishedSource(s, resultsById) : undefined;
|
|
1078
|
+
return source ? { record: unfinishedSourceRecord(s, phase.name, source) } as RanStep : runStep(s, phase.name, activities, profiles, resultsById, signal);
|
|
1079
|
+
}));
|
|
954
1080
|
const ran = settled.map((r) => r.record);
|
|
955
1081
|
for (const r of ran) gates.onRecord?.(r);
|
|
956
1082
|
const records = gateRecords.concat(ran);
|
|
@@ -981,7 +1107,7 @@ async function runPhase(
|
|
|
981
1107
|
}
|
|
982
1108
|
};
|
|
983
1109
|
|
|
984
|
-
for (let i =
|
|
1110
|
+
for (let i = from; i < steps.length; i++) {
|
|
985
1111
|
const step = steps[i];
|
|
986
1112
|
// The work lease (#2748): claimed here when it is due, and checked before
|
|
987
1113
|
// every step, so no step starts once the lease is lost.
|
|
@@ -1011,6 +1137,11 @@ async function runPhase(
|
|
|
1011
1137
|
}
|
|
1012
1138
|
continue;
|
|
1013
1139
|
}
|
|
1140
|
+
const source = gates.compensating ? unfinishedSource(step, resultsById) : undefined;
|
|
1141
|
+
if (source) {
|
|
1142
|
+
pushRecord(records, gates, unfinishedSourceRecord(step, phase.name, source));
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1014
1145
|
if (isEffect(step)) {
|
|
1015
1146
|
const { records: effRecords, failed, pending, pushed, pushWarning, point } = await runEffectStep(
|
|
1016
1147
|
step, phase.name, activities, profiles, resultsById, gates, signal,
|
|
@@ -1121,6 +1252,19 @@ export interface RunOpOptions {
|
|
|
1121
1252
|
stepOutput?: (line: string) => void;
|
|
1122
1253
|
/** How long a step may be quiet before the still-running line. Default 60s; `0` turns it off. */
|
|
1123
1254
|
stepHeartbeatMs?: number;
|
|
1255
|
+
/**
|
|
1256
|
+
* The commit the checkout is at, for an Op with a gate that resumes
|
|
1257
|
+
* (#3947). A checkpoint records it and a resumed run must match it.
|
|
1258
|
+
* Default: `git rev-parse HEAD` in `cwd`, and none when that fails, so no
|
|
1259
|
+
* gate resumes.
|
|
1260
|
+
*/
|
|
1261
|
+
commit?: string;
|
|
1262
|
+
/**
|
|
1263
|
+
* The activity contracts the run knows (#3947). A gate that resumes stores
|
|
1264
|
+
* no output a contract marks secret, and doesn't resume when a later step
|
|
1265
|
+
* needs one. The local runtime passes the configured lexicons' contracts.
|
|
1266
|
+
*/
|
|
1267
|
+
contracts?: ReadonlyMap<string, ActivityContract>;
|
|
1124
1268
|
}
|
|
1125
1269
|
|
|
1126
1270
|
/**
|
|
@@ -1292,12 +1436,12 @@ async function runOpInTurn(
|
|
|
1292
1436
|
const start = Date.now();
|
|
1293
1437
|
// Each phase's wall-clock time, for the run record and the in-flight one.
|
|
1294
1438
|
const phaseDurations: Record<string, number> = {};
|
|
1295
|
-
const timedPhase = async (phase: PhaseDefinition, ctx: GateContext, phaseSignal: AbortSignal | undefined): Promise<StepRecord[]> => {
|
|
1439
|
+
const timedPhase = async (phase: PhaseDefinition, ctx: GateContext, phaseSignal: AbortSignal | undefined, from = 0): Promise<StepRecord[]> => {
|
|
1296
1440
|
const live = currentLiveRun();
|
|
1297
1441
|
const t0 = Date.now();
|
|
1298
1442
|
live?.phaseStarted(phase.name);
|
|
1299
1443
|
try {
|
|
1300
|
-
const ran = await runPhase(phase, activities, profiles, resultsById, ctx, phaseSignal);
|
|
1444
|
+
const ran = await runPhase(phase, activities, profiles, resultsById, ctx, phaseSignal, from);
|
|
1301
1445
|
live?.phaseEnded(phase.name, phaseVerdict(ran), Date.now() - t0);
|
|
1302
1446
|
return ran;
|
|
1303
1447
|
} finally {
|
|
@@ -1326,6 +1470,7 @@ async function runOpInTurn(
|
|
|
1326
1470
|
...(gate ? { gate: { name: gate.gate, since: gate.timestamp, ...(gate.op !== config.name ? { op: gate.op } : {}) } } : {}),
|
|
1327
1471
|
...(point ? { point: { ...point, since: ended } } : {}),
|
|
1328
1472
|
...(options.steward ? { steward: options.steward } : {}),
|
|
1473
|
+
...(resumed ? { resumed } : {}),
|
|
1329
1474
|
phaseDurations,
|
|
1330
1475
|
});
|
|
1331
1476
|
const record: OpRunRecord = { version: 1, ...input, id: runId };
|
|
@@ -1343,10 +1488,96 @@ async function runOpInTurn(
|
|
|
1343
1488
|
// values before reaching an activity function — see `runStep`.
|
|
1344
1489
|
const resultsById = new Map<string, unknown>();
|
|
1345
1490
|
|
|
1491
|
+
// #3947: the gates that resume. Each stores a checkpoint when the run stops
|
|
1492
|
+
// at it, and the run may start at one of them.
|
|
1493
|
+
let resumed: OpRunResult["resumed"];
|
|
1494
|
+
const resumeNotes: string[] = [];
|
|
1495
|
+
const resumeGates = resumeGatesOf(config);
|
|
1496
|
+
if (resumeGates.length > 0) {
|
|
1497
|
+
gates.resume = {
|
|
1498
|
+
config,
|
|
1499
|
+
gates: new Map(resumeGates.map((g) => [g.step, g])),
|
|
1500
|
+
commit: options.commit ?? (await getHeadCommit(options.cwd ? { cwd: options.cwd } : undefined).catch(() => undefined)),
|
|
1501
|
+
...(options.contracts ? { contracts: options.contracts } : {}),
|
|
1502
|
+
note: (line) => resumeNotes.push(line),
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
const resumeFields = (): Pick<OpRunResult, "resumed" | "resumeNotes"> => ({
|
|
1506
|
+
...(resumed ? { resumed } : {}),
|
|
1507
|
+
...(resumeNotes.length > 0 ? { resumeNotes: [...resumeNotes] } : {}),
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* Resume at the gate `decision` names (#3947): seed the checkpoint's
|
|
1512
|
+
* outputs, record every step before the gate as resumed, and run the step
|
|
1513
|
+
* that produced the gate's plan again. Returns where the phases pick up, or
|
|
1514
|
+
* undefined when the plan moved and the run starts at the first phase.
|
|
1515
|
+
*/
|
|
1516
|
+
const resumeAt = async (
|
|
1517
|
+
decision: Extract<GateResumeDecision, { kind: "resume" }>,
|
|
1518
|
+
): Promise<{ phaseIndex: number; stepIndex: number } | undefined> => {
|
|
1519
|
+
const { gate, checkpoint } = decision;
|
|
1520
|
+
for (const [id, value] of Object.entries(checkpoint.outputs)) resultsById.set(id, value);
|
|
1521
|
+
const marker = { run: checkpoint.runId, gate: gate.name };
|
|
1522
|
+
const prefix: StepRecord[] = [];
|
|
1523
|
+
for (const [pi, phase] of config.phases.entries()) {
|
|
1524
|
+
if (pi > gate.phaseIndex) break;
|
|
1525
|
+
const steps = pi < gate.phaseIndex ? phase.steps : phase.parallel ? [] : phase.steps.slice(0, gate.stepIndex);
|
|
1526
|
+
for (const [si, step] of steps.entries()) {
|
|
1527
|
+
if (gate.planStep && pi === gate.planStep.phaseIndex && si === gate.planStep.stepIndex) {
|
|
1528
|
+
const ran = await runStep(gate.planStep.step, phase.name, activities, profiles, resultsById, stepSignal);
|
|
1529
|
+
prefix.push(ran.record);
|
|
1530
|
+
if (ran.record.status === "fail" || ran.pending || ran.record.point) {
|
|
1531
|
+
for (const r of prefix) gates.onRecord?.(r);
|
|
1532
|
+
if (ran.pending) throw new GateStop(prefix, ran.pending, phase.name);
|
|
1533
|
+
if (ran.record.point) throw new PointStop(prefix, ran.record.point, phase.name);
|
|
1534
|
+
throw new PhaseFailure(prefix);
|
|
1535
|
+
}
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
const skipped = isEffect(step)
|
|
1539
|
+
? skippedRecord(phase.name, `effect:${step.receipt.name}`)
|
|
1540
|
+
: isGate(step)
|
|
1541
|
+
? skippedRecord(phase.name, gateFn(step))
|
|
1542
|
+
: skippedRecord(phase.name, step.fn, step.args);
|
|
1543
|
+
prefix.push({ ...skipped, resumed: marker });
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
// The plan the run would approve now against the one approved: a moved
|
|
1547
|
+
// plan is live drift since the approval, and starts the run over.
|
|
1548
|
+
const resolved = resolveStepOutputRefs(gate.step.plan, resultsById);
|
|
1549
|
+
const planned = typeof resolved === "string" && resolved !== "" ? resolved : undefined;
|
|
1550
|
+
if (!samePlanDigest(planned, decision.pending.planDigest)) {
|
|
1551
|
+
resultsById.clear();
|
|
1552
|
+
resumeNotes.push(
|
|
1553
|
+
`gate "${gate.name}" is approved, and the run starts at the first phase: the plan moved since the approval ` +
|
|
1554
|
+
`(approved ${describePlanDigest(decision.pending.planDigest)}, planned ${planned ?? "(none)"})`,
|
|
1555
|
+
);
|
|
1556
|
+
return undefined;
|
|
1557
|
+
}
|
|
1558
|
+
records.push(...prefix);
|
|
1559
|
+
for (const r of prefix) gates.onRecord?.(r);
|
|
1560
|
+
resumed = { gate: gate.name, run: checkpoint.runId, checkpoint: decision.digest };
|
|
1561
|
+
return { phaseIndex: gate.phaseIndex, stepIndex: config.phases[gate.phaseIndex]!.parallel ? 0 : gate.stepIndex };
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1346
1564
|
try {
|
|
1347
|
-
|
|
1565
|
+
let from: { phaseIndex: number; stepIndex: number } | undefined;
|
|
1566
|
+
if (gates.resume) {
|
|
1567
|
+
const decision = await decideGateResume(gates.port, config, {
|
|
1568
|
+
now: options.now ?? new Date().toISOString(),
|
|
1569
|
+
commit: gates.resume.commit,
|
|
1570
|
+
...(options.env !== undefined ? { environment: options.env } : {}),
|
|
1571
|
+
});
|
|
1572
|
+
if (decision.kind === "refused") {
|
|
1573
|
+
resumeNotes.push(`gate "${decision.gate}" is approved, and the run starts at the first phase: ${decision.reason}`);
|
|
1574
|
+
} else if (decision.kind === "resume") {
|
|
1575
|
+
from = await resumeAt(decision);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
for (let pi = from?.phaseIndex ?? 0; pi < config.phases.length; pi++) {
|
|
1348
1579
|
if (signal?.aborted) throw new PhaseFailure([]);
|
|
1349
|
-
records.push(...(await timedPhase(
|
|
1580
|
+
records.push(...(await timedPhase(config.phases[pi]!, gates, stepSignal, pi === from?.phaseIndex ? from.stepIndex : 0)));
|
|
1350
1581
|
}
|
|
1351
1582
|
// The last renewal before the run is recorded (#2748): a run whose lease
|
|
1352
1583
|
// was lost is never recorded as done.
|
|
@@ -1366,6 +1597,7 @@ async function runOpInTurn(
|
|
|
1366
1597
|
startedAt,
|
|
1367
1598
|
record,
|
|
1368
1599
|
...(await finishWork("ok")),
|
|
1600
|
+
...resumeFields(),
|
|
1369
1601
|
};
|
|
1370
1602
|
}
|
|
1371
1603
|
|
|
@@ -1384,6 +1616,7 @@ async function runOpInTurn(
|
|
|
1384
1616
|
point: err.question,
|
|
1385
1617
|
record,
|
|
1386
1618
|
...(await finishWork("waiting")),
|
|
1619
|
+
...resumeFields(),
|
|
1387
1620
|
};
|
|
1388
1621
|
}
|
|
1389
1622
|
|
|
@@ -1404,6 +1637,7 @@ async function runOpInTurn(
|
|
|
1404
1637
|
...(err.pushWarning ? { gatePushWarning: err.pushWarning } : {}),
|
|
1405
1638
|
record,
|
|
1406
1639
|
...(await finishWork("gated")),
|
|
1640
|
+
...resumeFields(),
|
|
1407
1641
|
};
|
|
1408
1642
|
}
|
|
1409
1643
|
|
|
@@ -1444,7 +1678,7 @@ async function runOpInTurn(
|
|
|
1444
1678
|
// Compensation: run onFailure phases in reverse order (best-effort). Skipped
|
|
1445
1679
|
// on abort (Ctrl-C) — the user asked to stop, so don't start new work.
|
|
1446
1680
|
// It runs whatever the work lease did, so it takes no work boundary.
|
|
1447
|
-
const compensation: GateContext = { ...gates, work: undefined };
|
|
1681
|
+
const compensation: GateContext = { ...gates, work: undefined, compensating: true };
|
|
1448
1682
|
if (!signal?.aborted) {
|
|
1449
1683
|
for (const phase of [...(config.onFailure ?? [])].reverse()) {
|
|
1450
1684
|
try {
|
|
@@ -1478,6 +1712,7 @@ async function runOpInTurn(
|
|
|
1478
1712
|
startedAt,
|
|
1479
1713
|
record: failed,
|
|
1480
1714
|
...(await finishWork("fail")),
|
|
1715
|
+
...resumeFields(),
|
|
1481
1716
|
},
|
|
1482
1717
|
{ cause: err },
|
|
1483
1718
|
);
|
|
@@ -1492,6 +1727,7 @@ async function runOpInTurn(
|
|
|
1492
1727
|
startedAt,
|
|
1493
1728
|
record,
|
|
1494
1729
|
...(await finishWork("ok")),
|
|
1730
|
+
...resumeFields(),
|
|
1495
1731
|
};
|
|
1496
1732
|
}
|
|
1497
1733
|
|
package/src/op/local-output.ts
CHANGED
|
@@ -28,6 +28,10 @@ function formatDuration(ms: number): string {
|
|
|
28
28
|
* stays clean for piping (the `--json` renderer owns stdout).
|
|
29
29
|
*/
|
|
30
30
|
export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed = false): void {
|
|
31
|
+
// #3947: a run that resumed at a gate says where it picked up.
|
|
32
|
+
if (result.resumed) {
|
|
33
|
+
write(`[resume] at gate "${result.resumed.gate}", from run ${result.resumed.run}'s checkpoint ${result.resumed.checkpoint}`);
|
|
34
|
+
}
|
|
31
35
|
let currentPhase: string | undefined;
|
|
32
36
|
for (const record of result.records) {
|
|
33
37
|
if (record.phase !== currentPhase) {
|
|
@@ -36,7 +40,9 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
|
|
|
36
40
|
}
|
|
37
41
|
const mark = record.status === "ok" ? "✓" : record.status === "fail" ? "✗" : "•";
|
|
38
42
|
const call = `${record.fn}(${formatArgs(record.args ?? {})})`;
|
|
39
|
-
if (record.status === "skipped") {
|
|
43
|
+
if (record.status === "skipped" && record.resumed) {
|
|
44
|
+
write(` ${mark} ${call} from run ${record.resumed.run}`);
|
|
45
|
+
} else if (record.status === "skipped") {
|
|
40
46
|
write(` ${mark} ${call} skipped`);
|
|
41
47
|
} else {
|
|
42
48
|
write(` ${mark} ${call} ${formatDuration(record.durationMs)}`);
|
|
@@ -47,6 +53,10 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
|
|
|
47
53
|
if (record.approval) {
|
|
48
54
|
write(` [approved] ${record.approval.resolvedBy} at ${record.approval.timestamp}` +
|
|
49
55
|
(record.approval.url ? ` (${record.approval.url})` : ""));
|
|
56
|
+
// #3949: an approval carried from one of the gate's aliases names where it came from.
|
|
57
|
+
for (const c of record.approval.carriedFrom ?? []) {
|
|
58
|
+
write(` [carried] ${c.resolvedBy}'s approval of ${c.op}/${c.gate} at ${c.timestamp}`);
|
|
59
|
+
}
|
|
50
60
|
}
|
|
51
61
|
if (record.refusal) {
|
|
52
62
|
write(` [refused] ${record.refusal}`);
|
|
@@ -72,6 +82,8 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
|
|
|
72
82
|
write(`[work] nothing claimed: ${work.refusal}`);
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
for (const note of result.resumeNotes ?? []) write(`[resume] ${note}`);
|
|
86
|
+
|
|
75
87
|
const total = `${(result.totalMs / 1000).toFixed(1)}s`;
|
|
76
88
|
if (result.status === "ok") {
|
|
77
89
|
write(`Op "${result.op}" completed in ${total}`);
|
|
@@ -104,6 +116,8 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
|
|
|
104
116
|
write(` approve : ${approveCommand(gate.op, gate.gate, undefined, gate.planDigest, sealed)}`);
|
|
105
117
|
if (gate.url) write(` approve at: ${gate.url}`);
|
|
106
118
|
write(` expires : ${gate.expiresAt}`);
|
|
119
|
+
// #3947: the approval also lets the next run start here.
|
|
120
|
+
if (gate.checkpointDigest) write(` resume : once approved, the next run starts at this gate (checkpoint ${gate.checkpointDigest})`);
|
|
107
121
|
// #2310: this run's own append reached only the local chant/lifecycle
|
|
108
122
|
// branch, not the remote. The pending fact is still correct — the gate is
|
|
109
123
|
// still right to stand — but an operator working from a clone of the
|
|
@@ -139,7 +153,9 @@ export function renderJson(result: OpRunResult, write: Writer = stdout, sealed =
|
|
|
139
153
|
const push = result.gatePushed === false
|
|
140
154
|
? { pushed: false, pushWarning: result.gatePushWarning ?? "the pending fact was recorded locally only" }
|
|
141
155
|
: {};
|
|
142
|
-
|
|
156
|
+
// #3947: why a gate that resumes did not, beside the record like `push`.
|
|
157
|
+
const notes = result.resumeNotes ? { resumeNotes: result.resumeNotes } : {};
|
|
158
|
+
write(JSON.stringify({ ...result.record, ...approve, ...push, ...notes }));
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
export type { OpRunResult, StepRecord };
|
package/src/op/op-ir.test.ts
CHANGED
|
@@ -257,6 +257,31 @@ describe("op.json IR", () => {
|
|
|
257
257
|
expect(serializeOpIR(reconstructed)).toBe(text);
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
+
// #3947: a gate that resumes says so in op.json, for a runtime that reads it.
|
|
261
|
+
it("carries a gate's resume through op.json and back", () => {
|
|
262
|
+
const config: OpConfig = {
|
|
263
|
+
name: "live-apply",
|
|
264
|
+
overview: "o",
|
|
265
|
+
phases: [phase("Gate", [{ kind: "gate", gate: "approve-live-apply", resume: true }])],
|
|
266
|
+
};
|
|
267
|
+
const text = serializeOpIR(config);
|
|
268
|
+
expect((JSON.parse(text) as OpIR).phases[0].steps[0]).toMatchObject({ kind: "gate", resume: true });
|
|
269
|
+
expect(opConfigFromIR(JSON.parse(text) as OpIR).phases[0]!.steps[0]).toMatchObject({ kind: "gate", resume: true });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// #3949: a gate's aliases travel in op.json, so a runtime that reads it counts the same approvals.
|
|
273
|
+
it("carries a gate's aliases through op.json and back", () => {
|
|
274
|
+
const aliases = [{ op: "upgrade-tier-1", gate: "approve" }, { gate: "approve-old" }];
|
|
275
|
+
const config: OpConfig = {
|
|
276
|
+
name: "upgrade",
|
|
277
|
+
overview: "o",
|
|
278
|
+
phases: [phase("Gate", [{ kind: "gate", gate: "approve-tier-1", plan: "jcs1-sha256:" + "a".repeat(64), aliases }])],
|
|
279
|
+
};
|
|
280
|
+
const text = serializeOpIR(config);
|
|
281
|
+
expect((JSON.parse(text) as OpIR).phases[0].steps[0]).toMatchObject({ kind: "gate", aliases });
|
|
282
|
+
expect(opConfigFromIR(JSON.parse(text) as OpIR).phases[0]!.steps[0]).toMatchObject({ kind: "gate", aliases });
|
|
283
|
+
});
|
|
284
|
+
|
|
260
285
|
it("round-trips a minimal Op (no gate, effect, onFailure or labels) too", () => {
|
|
261
286
|
const original: OpConfig = { name: "minimal", overview: "o", phases: [phase("Only", [shell("echo hi")])] };
|
|
262
287
|
const text = serializeOpIR(original);
|
package/src/op/op-ir.ts
CHANGED
|
@@ -151,6 +151,10 @@ export interface OpIRGateStep {
|
|
|
151
151
|
approval?: GateStep["approval"];
|
|
152
152
|
/** The decision point the gate asks (#3170), carried as authored. Absent on a gate that `chant approve` passes. */
|
|
153
153
|
point?: GateStep["point"];
|
|
154
|
+
/** The gate resumes once approved (#3947). Absent on a gate that starts its run again at the first phase. */
|
|
155
|
+
resume?: true;
|
|
156
|
+
/** The gate's old names (#3949), carried as authored. Absent on a gate that has none. */
|
|
157
|
+
aliases?: GateStep["aliases"];
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
export interface OpIREffectStep {
|
|
@@ -263,6 +267,8 @@ function irGateStep(step: GateStep): OpIRGateStep {
|
|
|
263
267
|
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
264
268
|
...(step.approval !== undefined ? { approval: step.approval } : {}),
|
|
265
269
|
...(step.point !== undefined ? { point: step.point } : {}),
|
|
270
|
+
...(step.resume ? { resume: true as const } : {}),
|
|
271
|
+
...(step.aliases !== undefined && step.aliases.length > 0 ? { aliases: step.aliases } : {}),
|
|
266
272
|
};
|
|
267
273
|
}
|
|
268
274
|
|
|
@@ -419,6 +425,8 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
|
|
|
419
425
|
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
420
426
|
...(step.approval !== undefined ? { approval: step.approval } : {}),
|
|
421
427
|
...(step.point !== undefined ? { point: step.point } : {}),
|
|
428
|
+
...(step.resume ? { resume: true } : {}),
|
|
429
|
+
...(step.aliases !== undefined && step.aliases.length > 0 ? { aliases: step.aliases } : {}),
|
|
422
430
|
};
|
|
423
431
|
}
|
|
424
432
|
return {
|
package/src/op/receipt-store.ts
CHANGED
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
referenceInputPaths,
|
|
35
35
|
type EffectReceiptDeclaration,
|
|
36
36
|
type EffectReceiptFlavor,
|
|
37
|
+
declaredExpectation,
|
|
38
|
+
withStepOutputs,
|
|
37
39
|
} from "../effect-receipt";
|
|
38
40
|
|
|
39
41
|
/**
|
|
@@ -101,7 +103,14 @@ export function receiptCheckInput(receipt: EffectReceiptDeclaration): ReceiptChe
|
|
|
101
103
|
// ── Activity contracts ────────────────────────────────────────────────────────
|
|
102
104
|
|
|
103
105
|
/** Args of the `receiptRead` activity, as the effect step's codegen emits them. */
|
|
104
|
-
export interface ReceiptReadArgs extends ReceiptCheckInput {
|
|
106
|
+
export interface ReceiptReadArgs extends ReceiptCheckInput {
|
|
107
|
+
/**
|
|
108
|
+
* The digest of the step outputs the effect's nested steps read (#3948),
|
|
109
|
+
* when they read any. Folded into the expectation by {@link withStepOutputs}.
|
|
110
|
+
*/
|
|
111
|
+
outputs?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
105
114
|
|
|
106
115
|
/** Result of the `receiptRead` activity. `current` is `null` (not `undefined`)
|
|
107
116
|
* for an absent receipt so the value survives JSON transport. */
|
|
@@ -183,7 +192,8 @@ export function receiptActivities(store: ReceiptStore, opts?: ReceiptActivityOpt
|
|
|
183
192
|
|
|
184
193
|
return {
|
|
185
194
|
async receiptRead(args: ReceiptReadArgs): Promise<ReceiptReadResult> {
|
|
186
|
-
const
|
|
195
|
+
const declared = await expectationOf(args);
|
|
196
|
+
const expectation = args.outputs !== undefined ? withStepOutputs(declared, args.outputs) : declared;
|
|
187
197
|
const current = await store.read(args.receipt);
|
|
188
198
|
return { current: current ?? null, expectation, applied: current === expectation };
|
|
189
199
|
},
|
|
@@ -201,7 +211,7 @@ export function receiptActivities(store: ReceiptStore, opts?: ReceiptActivityOpt
|
|
|
201
211
|
const current = await store.read(input.receipt);
|
|
202
212
|
if (current === undefined) {
|
|
203
213
|
findings.push({ receipt: input.receipt.name, effect: input.receipt.effect, kind: "absent", expected });
|
|
204
|
-
} else if (current !== expected) {
|
|
214
|
+
} else if (declaredExpectation(current) !== expected) {
|
|
205
215
|
findings.push({ receipt: input.receipt.name, effect: input.receipt.effect, kind: "differs", expected, current });
|
|
206
216
|
}
|
|
207
217
|
}
|
package/src/op/runtime.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface OpRunStepRecord {
|
|
|
59
59
|
point?: WaitingPoint;
|
|
60
60
|
/** The gate this step's command stopped at (#2779), for a step skipped because of it. */
|
|
61
61
|
gate?: { op: string; gate: string };
|
|
62
|
+
/** The run whose checkpoint supplied this step's output, for a step a resumed run skipped (#3947). */
|
|
63
|
+
resumed?: { run: string; gate: string };
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
/** One phase's steps and the verdict they add up to. */
|
|
@@ -151,6 +153,12 @@ export interface OpRunRecord {
|
|
|
151
153
|
point?: WaitingPoint & { since: string };
|
|
152
154
|
/** The steward whose turn the run was, when a steward ran it (#2749). */
|
|
153
155
|
steward?: string;
|
|
156
|
+
/**
|
|
157
|
+
* The gate this run resumed at (#3947), the run whose checkpoint it carried
|
|
158
|
+
* on from, and the checkpoint's digest. Absent on a run that started at the
|
|
159
|
+
* first phase.
|
|
160
|
+
*/
|
|
161
|
+
resumed?: { gate: string; run: string; checkpoint: string };
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
/** An {@link OpRunRecord} before the ledger stamps its version and mints an id. */
|