@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
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.
|
|
@@ -31,6 +37,7 @@ import { stepLabel, withRunConsole, withStepConsole } from "./step-console";
|
|
|
31
37
|
import { stepTimeoutProblem } from "./activity-profiles";
|
|
32
38
|
import { describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
|
|
33
39
|
import { gateName } from "./gate-name";
|
|
40
|
+
import { resolveGateAliases } from "./gate-alias";
|
|
34
41
|
import { gatePointOf } from "./gate-point";
|
|
35
42
|
import { evaluatePointGate, workspaceGatePointAsker, type GatePointAsker } from "./gate-point-run";
|
|
36
43
|
import type { ResolvedGateApproval } from "./gate-approval";
|
|
@@ -47,6 +54,11 @@ import { RunWorkLease, LEASE_LOST, workLeaseProblems, workLeaseNeedsRunItem, typ
|
|
|
47
54
|
import { WORK_LEASE_STEP_ID } from "./types";
|
|
48
55
|
import { LiveRun, currentLiveRun, withLiveRun } from "./run-live";
|
|
49
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";
|
|
50
62
|
|
|
51
63
|
export { parseDuration } from "./duration";
|
|
52
64
|
|
|
@@ -78,12 +90,17 @@ export interface StepRecord {
|
|
|
78
90
|
resolvedBy: string;
|
|
79
91
|
timestamp: string;
|
|
80
92
|
url?: string;
|
|
81
|
-
/**
|
|
82
|
-
|
|
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";
|
|
83
98
|
/** On a gate with a quorum (#2508): every approver who counted toward it. */
|
|
84
99
|
approvers?: string[];
|
|
85
100
|
/** On a gate that asks a decision point (#3170): the answer record that passed it, which the gate ledger's resolution cites too. */
|
|
86
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 }>;
|
|
87
104
|
};
|
|
88
105
|
/**
|
|
89
106
|
* Why a step declined to proceed on something that is not a failure
|
|
@@ -105,6 +122,12 @@ export interface StepRecord {
|
|
|
105
122
|
* approve` takes. The step's status is `skipped` and the run's is `gated`.
|
|
106
123
|
*/
|
|
107
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 };
|
|
108
131
|
}
|
|
109
132
|
|
|
110
133
|
export interface OpRunResult {
|
|
@@ -151,6 +174,18 @@ export interface OpRunResult {
|
|
|
151
174
|
* was released and with which outcome, or why it was lost.
|
|
152
175
|
*/
|
|
153
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[];
|
|
154
189
|
}
|
|
155
190
|
|
|
156
191
|
// ── Errors ────────────────────────────────────────────────────────────────��─
|
|
@@ -547,6 +582,14 @@ interface GateContext {
|
|
|
547
582
|
points?: GatePointAsker;
|
|
548
583
|
/** The environment the run was started for, a point gate's `gate.env` input. */
|
|
549
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
|
+
};
|
|
550
593
|
}
|
|
551
594
|
|
|
552
595
|
/**
|
|
@@ -679,6 +722,22 @@ async function runGateStep(
|
|
|
679
722
|
const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
|
|
680
723
|
const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
|
|
681
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
|
+
}
|
|
682
741
|
let check: GateCheck;
|
|
683
742
|
try {
|
|
684
743
|
check = await evaluateGate(gates.port, {
|
|
@@ -690,6 +749,8 @@ async function runGateStep(
|
|
|
690
749
|
...(planDigest !== undefined ? { planDigest } : {}),
|
|
691
750
|
...(approval ? { approval } : {}),
|
|
692
751
|
...(gates.now ? { now: gates.now } : {}),
|
|
752
|
+
...(checkpoint ? { checkpoint } : {}),
|
|
753
|
+
...(step.aliases?.length ? { aliases: resolveGateAliases(step, gates.op) } : {}),
|
|
693
754
|
});
|
|
694
755
|
} catch (err) {
|
|
695
756
|
// Deciding a gate reads and writes the `chant/lifecycle` branch, so it
|
|
@@ -721,6 +782,9 @@ async function runGateStep(
|
|
|
721
782
|
...(resolution.url ? { url: resolution.url } : {}),
|
|
722
783
|
...(check.via ? { via: check.via } : {}),
|
|
723
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
|
+
: {}),
|
|
724
788
|
};
|
|
725
789
|
// #2522: a later step reads this gate's approval from the run context
|
|
726
790
|
// rather than re-tallying the ledger, so it sees the decision this run made.
|
|
@@ -737,6 +801,7 @@ async function runGateStep(
|
|
|
737
801
|
};
|
|
738
802
|
}
|
|
739
803
|
|
|
804
|
+
if (blocked) gates.resume?.note(`gate "${gateName(step)}" stored no checkpoint, so it won't resume: ${blocked}`);
|
|
740
805
|
const refusal = check.mismatch
|
|
741
806
|
? { refusal: describeGateMismatch(gates.op, gateName(step), check.mismatch, await gateIsSealed(gateName(step))) }
|
|
742
807
|
: {};
|
|
@@ -952,6 +1017,8 @@ async function runEffectStep(
|
|
|
952
1017
|
/**
|
|
953
1018
|
* Run a phase. Throws `PhaseFailure` (with the records so far) if any step
|
|
954
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.
|
|
955
1022
|
*/
|
|
956
1023
|
async function runPhase(
|
|
957
1024
|
phase: PhaseDefinition,
|
|
@@ -960,6 +1027,7 @@ async function runPhase(
|
|
|
960
1027
|
resultsById: Map<string, unknown>,
|
|
961
1028
|
gates: GateContext,
|
|
962
1029
|
signal?: AbortSignal,
|
|
1030
|
+
from = 0,
|
|
963
1031
|
): Promise<StepRecord[]> {
|
|
964
1032
|
if (phase.parallel) {
|
|
965
1033
|
const eff = phase.steps.find(isEffect);
|
|
@@ -1039,7 +1107,7 @@ async function runPhase(
|
|
|
1039
1107
|
}
|
|
1040
1108
|
};
|
|
1041
1109
|
|
|
1042
|
-
for (let i =
|
|
1110
|
+
for (let i = from; i < steps.length; i++) {
|
|
1043
1111
|
const step = steps[i];
|
|
1044
1112
|
// The work lease (#2748): claimed here when it is due, and checked before
|
|
1045
1113
|
// every step, so no step starts once the lease is lost.
|
|
@@ -1184,6 +1252,19 @@ export interface RunOpOptions {
|
|
|
1184
1252
|
stepOutput?: (line: string) => void;
|
|
1185
1253
|
/** How long a step may be quiet before the still-running line. Default 60s; `0` turns it off. */
|
|
1186
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>;
|
|
1187
1268
|
}
|
|
1188
1269
|
|
|
1189
1270
|
/**
|
|
@@ -1355,12 +1436,12 @@ async function runOpInTurn(
|
|
|
1355
1436
|
const start = Date.now();
|
|
1356
1437
|
// Each phase's wall-clock time, for the run record and the in-flight one.
|
|
1357
1438
|
const phaseDurations: Record<string, number> = {};
|
|
1358
|
-
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[]> => {
|
|
1359
1440
|
const live = currentLiveRun();
|
|
1360
1441
|
const t0 = Date.now();
|
|
1361
1442
|
live?.phaseStarted(phase.name);
|
|
1362
1443
|
try {
|
|
1363
|
-
const ran = await runPhase(phase, activities, profiles, resultsById, ctx, phaseSignal);
|
|
1444
|
+
const ran = await runPhase(phase, activities, profiles, resultsById, ctx, phaseSignal, from);
|
|
1364
1445
|
live?.phaseEnded(phase.name, phaseVerdict(ran), Date.now() - t0);
|
|
1365
1446
|
return ran;
|
|
1366
1447
|
} finally {
|
|
@@ -1389,6 +1470,7 @@ async function runOpInTurn(
|
|
|
1389
1470
|
...(gate ? { gate: { name: gate.gate, since: gate.timestamp, ...(gate.op !== config.name ? { op: gate.op } : {}) } } : {}),
|
|
1390
1471
|
...(point ? { point: { ...point, since: ended } } : {}),
|
|
1391
1472
|
...(options.steward ? { steward: options.steward } : {}),
|
|
1473
|
+
...(resumed ? { resumed } : {}),
|
|
1392
1474
|
phaseDurations,
|
|
1393
1475
|
});
|
|
1394
1476
|
const record: OpRunRecord = { version: 1, ...input, id: runId };
|
|
@@ -1406,10 +1488,96 @@ async function runOpInTurn(
|
|
|
1406
1488
|
// values before reaching an activity function — see `runStep`.
|
|
1407
1489
|
const resultsById = new Map<string, unknown>();
|
|
1408
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
|
+
|
|
1409
1564
|
try {
|
|
1410
|
-
|
|
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++) {
|
|
1411
1579
|
if (signal?.aborted) throw new PhaseFailure([]);
|
|
1412
|
-
records.push(...(await timedPhase(
|
|
1580
|
+
records.push(...(await timedPhase(config.phases[pi]!, gates, stepSignal, pi === from?.phaseIndex ? from.stepIndex : 0)));
|
|
1413
1581
|
}
|
|
1414
1582
|
// The last renewal before the run is recorded (#2748): a run whose lease
|
|
1415
1583
|
// was lost is never recorded as done.
|
|
@@ -1429,6 +1597,7 @@ async function runOpInTurn(
|
|
|
1429
1597
|
startedAt,
|
|
1430
1598
|
record,
|
|
1431
1599
|
...(await finishWork("ok")),
|
|
1600
|
+
...resumeFields(),
|
|
1432
1601
|
};
|
|
1433
1602
|
}
|
|
1434
1603
|
|
|
@@ -1447,6 +1616,7 @@ async function runOpInTurn(
|
|
|
1447
1616
|
point: err.question,
|
|
1448
1617
|
record,
|
|
1449
1618
|
...(await finishWork("waiting")),
|
|
1619
|
+
...resumeFields(),
|
|
1450
1620
|
};
|
|
1451
1621
|
}
|
|
1452
1622
|
|
|
@@ -1467,6 +1637,7 @@ async function runOpInTurn(
|
|
|
1467
1637
|
...(err.pushWarning ? { gatePushWarning: err.pushWarning } : {}),
|
|
1468
1638
|
record,
|
|
1469
1639
|
...(await finishWork("gated")),
|
|
1640
|
+
...resumeFields(),
|
|
1470
1641
|
};
|
|
1471
1642
|
}
|
|
1472
1643
|
|
|
@@ -1541,6 +1712,7 @@ async function runOpInTurn(
|
|
|
1541
1712
|
startedAt,
|
|
1542
1713
|
record: failed,
|
|
1543
1714
|
...(await finishWork("fail")),
|
|
1715
|
+
...resumeFields(),
|
|
1544
1716
|
},
|
|
1545
1717
|
{ cause: err },
|
|
1546
1718
|
);
|
|
@@ -1555,6 +1727,7 @@ async function runOpInTurn(
|
|
|
1555
1727
|
startedAt,
|
|
1556
1728
|
record,
|
|
1557
1729
|
...(await finishWork("ok")),
|
|
1730
|
+
...resumeFields(),
|
|
1558
1731
|
};
|
|
1559
1732
|
}
|
|
1560
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/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. */
|
package/src/op/runtimes/local.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { lexiconNames } from "../../lexicon-module";
|
|
|
22
22
|
import { loadChantConfig } from "../../config";
|
|
23
23
|
import { loadActivities, loadProfiles } from "../activity-registry";
|
|
24
24
|
import { runOpLocally, OpRunFailure, type OpRunResult } from "../local-executor";
|
|
25
|
+
import { loadActivityContracts } from "../activity-contract-registry";
|
|
26
|
+
import { resumeGatesOf } from "../gate-checkpoint";
|
|
25
27
|
import { stepHeartbeatMs } from "../step-console";
|
|
26
28
|
import { runComponents } from "../../components/cli-support";
|
|
27
29
|
import { discoverOps } from "../discover";
|
|
@@ -139,6 +141,8 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
const [activities, profiles] = await Promise.all([loadActivities(lexicons), loadProfiles()]);
|
|
144
|
+
// #3947: a gate that resumes stores no output a contract marks secret.
|
|
145
|
+
const contracts = resumeGatesOf(op).length > 0 ? await loadActivityContracts(lexicons) : undefined;
|
|
142
146
|
|
|
143
147
|
// A caller that names the run (#3539: `chant acp` on fountain passes the
|
|
144
148
|
// conversation id) gets its id on the record; otherwise one is minted.
|
|
@@ -179,6 +183,7 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
179
183
|
stepOutput: (line) => process.stderr.write(`${line}\n`),
|
|
180
184
|
stepHeartbeatMs: stepHeartbeatMs(process.env, (message) => process.stderr.write(`warning: ${message}\n`)),
|
|
181
185
|
...(startOpts.work ? { work: startOpts.work } : {}),
|
|
186
|
+
...(contracts ? { contracts } : {}),
|
|
182
187
|
onWorkLeaseWarning: (message) =>
|
|
183
188
|
process.stderr.write(`warning: "${op.name}" could not renew its work lease, retrying at the next beat: ${message}\n`),
|
|
184
189
|
},
|
package/src/op/types.ts
CHANGED
|
@@ -298,6 +298,38 @@ export interface GateStepBase {
|
|
|
298
298
|
* `./gate-point.ts`.
|
|
299
299
|
*/
|
|
300
300
|
point?: string | GatePoint;
|
|
301
|
+
/**
|
|
302
|
+
* A run that stops at this gate can be resumed here once it is approved
|
|
303
|
+
* (#3947), rather than starting again at the first phase. The run stores
|
|
304
|
+
* the step outputs a later step references in a checkpoint on
|
|
305
|
+
* `chant/lifecycle`, and the next run after `chant approve` re-runs the
|
|
306
|
+
* step that produced the gate's `plan`, checks the checkpoint, and carries
|
|
307
|
+
* on from the gate. Any check that fails starts the run at the first phase,
|
|
308
|
+
* and the run says why. See `./gate-checkpoint.ts`.
|
|
309
|
+
*
|
|
310
|
+
* Only a gate at the top of a main phase resumes, and not one that asks a
|
|
311
|
+
* decision point or sits in an Op with a `workLease`. OPS016 refuses the
|
|
312
|
+
* rest at build.
|
|
313
|
+
*/
|
|
314
|
+
resume?: boolean;
|
|
315
|
+
/**
|
|
316
|
+
* The names this gate had before an Op was restructured (#3949), so the
|
|
317
|
+
* approvals already recorded under them still count. `op` defaults to this
|
|
318
|
+
* Op. An approval recorded under an alias counts toward this gate when it
|
|
319
|
+
* answers that name's standing pending fact, the fact hasn't expired, it
|
|
320
|
+
* is for the plan this run planned and the same environment, it hasn't
|
|
321
|
+
* been spent, it passes this gate's `identity.gates` rule, and a person
|
|
322
|
+
* made it. The run then writes it again under this gate's name, citing
|
|
323
|
+
* the line it came from (`carriedFrom`). Only a gate bound to a `plan`
|
|
324
|
+
* takes aliases, and OPS017 refuses an alias that is still a gate.
|
|
325
|
+
*/
|
|
326
|
+
aliases?: GateAlias[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A name a gate had before (#3949): a gate of `op`, or of the gate's own Op when `op` is absent. */
|
|
330
|
+
export interface GateAlias {
|
|
331
|
+
op?: string;
|
|
332
|
+
gate: string;
|
|
301
333
|
}
|
|
302
334
|
|
|
303
335
|
/**
|
|
@@ -11,7 +11,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { afterAll, describe, expect, test } from "vitest";
|
|
14
|
-
import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "./gate-seal";
|
|
14
|
+
import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, gateSealVersion, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "./gate-seal";
|
|
15
15
|
import { checkGateSeal } from "./seal";
|
|
16
16
|
import { emptyPolicy, parseAllowedSigners, type TrustPolicy } from "./policy";
|
|
17
17
|
import { hasSshKeygen } from "./test-repo";
|
|
@@ -96,7 +96,7 @@ describe.skipIf(!hasSshKeygen)("an SSHSIG checked with node:crypto", () => {
|
|
|
96
96
|
const policy = policyOf(`alice ${k.pub}\n`);
|
|
97
97
|
const seal = { signer: "alice", key: "SHA256:x", signature };
|
|
98
98
|
expect(both(policy, { ...a, seal })).toEqual({ ours: null, keygen: true });
|
|
99
|
-
for (const edited of [{ planDigest: "jcs1-sha256:other" }, { timestamp: "2026-10-12T00:00:00.000Z" }, { relayedBy: "bot" }, { environment: "prod" }]) {
|
|
99
|
+
for (const edited of [{ planDigest: "jcs1-sha256:other" }, { timestamp: "2026-10-12T00:00:00.000Z" }, { relayedBy: "bot" }, { environment: "prod" }, { checkpointDigest: "jcs1-sha256:added" }]) {
|
|
100
100
|
const res = both(policy, { ...a, ...edited, seal });
|
|
101
101
|
expect(res.keygen).toBe(false);
|
|
102
102
|
expect(res.ours).toMatch(/does not verify for alice/);
|
|
@@ -137,6 +137,33 @@ describe.skipIf(!hasSshKeygen)("an SSHSIG checked with node:crypto", () => {
|
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
// #3947: seal version 2 signs the checkpoint digest as one more line, so the
|
|
141
|
+
// outputs a resumed run carries on with are covered, and a version 1 payload
|
|
142
|
+
// is unchanged.
|
|
143
|
+
describe("seal version 2 covers the checkpoint", () => {
|
|
144
|
+
test("the payload adds a checkpoint line only when the approval carries one", () => {
|
|
145
|
+
const a = approval({ relayedBy: "bot" });
|
|
146
|
+
expect(gateSealVersion(a)).toBe(1);
|
|
147
|
+
expect(gateSealPayload(a).toString()).toBe("migrations\nmigrations-wave-1\n\njcs1-sha256:abc\nalice\n2026-10-11T00:00:00.000Z\nrelayed-by bot");
|
|
148
|
+
const b = approval({ relayedBy: "bot", checkpointDigest: "jcs1-sha256:cp" });
|
|
149
|
+
expect(gateSealVersion(b)).toBe(2);
|
|
150
|
+
expect(gateSealPayload(b).toString()).toBe(`${gateSealPayload(a).toString()}\ncheckpoint jcs1-sha256:cp`);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test.skipIf(!hasSshKeygen)("a version 2 seal verifies, and swapping or removing the checkpoint stops it", () => {
|
|
154
|
+
const k = key("alice-cp", ["-t", "ed25519"]);
|
|
155
|
+
const a = approval({ checkpointDigest: "jcs1-sha256:cp" });
|
|
156
|
+
const seal = { signer: "alice", key: "SHA256:x", signature: sign(k, gateSealPayload(a)), version: 2 };
|
|
157
|
+
const policy = policyOf(`alice ${k.pub}\n`);
|
|
158
|
+
expect(both(policy, { ...a, seal })).toEqual({ ours: null, keygen: true });
|
|
159
|
+
for (const edited of [{ checkpointDigest: "jcs1-sha256:other" }, { checkpointDigest: undefined }]) {
|
|
160
|
+
const res = both(policy, { ...a, ...edited, seal });
|
|
161
|
+
expect(res.keygen).toBe(false);
|
|
162
|
+
expect(res.ours).toMatch(/does not verify for alice/);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
140
167
|
describe("the refusals that need no signature", () => {
|
|
141
168
|
const signers = (over: Partial<GateSigners> = {}): GateSigners => ({ base: "a".repeat(40), signersPath: ".chant/allowed_signers", active: true, signers: [], problems: [], ...over });
|
|
142
169
|
|