@intentius/chant 0.61.0 → 0.63.0
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/handlers/operator.d.ts +13 -18
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +117 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +394 -14
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +34 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +232 -1
- package/src/cli/handlers/operator.ts +130 -6
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +134 -39
- package/src/op/activities/lexicon-upgrade.ts +74 -12
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +1013 -1
- package/src/op/activities/reconcile.ts +721 -25
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +614 -0
- package/src/op/gate.ts +190 -24
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +340 -2
- package/src/op/local-executor.ts +190 -32
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtime.ts +2 -0
- package/src/op/runtimes/local.ts +11 -0
- package/src/op/types.ts +19 -0
- package/src/runtime-adapter.ts +17 -3
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
package/src/op/local-executor.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activi
|
|
|
25
25
|
import type { ReceiptReadResult } from "./receipt-store";
|
|
26
26
|
import { isStepOutputRef } from "./step-output-ref";
|
|
27
27
|
import { parseDuration } from "./duration";
|
|
28
|
-
import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
28
|
+
import { describeGateMismatch, evaluateGate, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
|
|
29
29
|
import { gateName } from "./gate-name";
|
|
30
30
|
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
31
31
|
import { appendRunRecord, buildRunRecord } from "../lifecycle/run-ledger";
|
|
@@ -58,6 +58,14 @@ export interface StepRecord {
|
|
|
58
58
|
error?: string;
|
|
59
59
|
/** Set on a `gate` step that passed (#2119): who resolved it, when, and at what address. */
|
|
60
60
|
approval?: { gate: string; resolvedBy: string; timestamp: string; url?: string };
|
|
61
|
+
/**
|
|
62
|
+
* Why a step declined to proceed on something that is not a failure
|
|
63
|
+
* (#2300): a gate holding a standing approval for a *different* plan. Names
|
|
64
|
+
* both digests and the `chant approve` line that closes the gap. The step's
|
|
65
|
+
* status is `skipped` and the run's is `gated` — nothing broke, and nothing
|
|
66
|
+
* was applied.
|
|
67
|
+
*/
|
|
68
|
+
refusal?: string;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
export interface OpRunResult {
|
|
@@ -76,6 +84,15 @@ export interface OpRunResult {
|
|
|
76
84
|
startedAt: string;
|
|
77
85
|
/** Present when `status === "gated"`: the pending fact the run ended on. */
|
|
78
86
|
gate?: PendingGateRecord;
|
|
87
|
+
/**
|
|
88
|
+
* Present when `status === "gated"` and this run's own append tried to push:
|
|
89
|
+
* whether it reached the remote (#2310). Absent when the run stopped on a
|
|
90
|
+
* pending fact an earlier run had already recorded — nothing was pushed
|
|
91
|
+
* this run.
|
|
92
|
+
*/
|
|
93
|
+
gatePushed?: boolean;
|
|
94
|
+
/** Set when `gatePushed` is false: why, in one line. */
|
|
95
|
+
gatePushWarning?: string;
|
|
79
96
|
/**
|
|
80
97
|
* The run's ledger record (#2118) — always built, whether or not it was
|
|
81
98
|
* appended. `chant run <op> --json` prints exactly this, so what a caller
|
|
@@ -87,11 +104,31 @@ export interface OpRunResult {
|
|
|
87
104
|
|
|
88
105
|
// ── Errors ────────────────────────────────────────────────────────────────��─
|
|
89
106
|
|
|
90
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Thrown on terminal Op failure; carries the partial run result for rendering.
|
|
109
|
+
*
|
|
110
|
+
* `cause` is what actually went wrong (#2301). Before it, this class was the
|
|
111
|
+
* end of the line for any error that was not a `PhaseFailure` — the executor
|
|
112
|
+
* caught it, built an `OpRunFailure` from the records it had, and never
|
|
113
|
+
* referenced the error again. Anything thrown outside a step's own
|
|
114
|
+
* error-capturing path therefore reached the operator as `Op "x" failed` and
|
|
115
|
+
* nothing else: no error line, no failing step, exit 1. That is how a gate
|
|
116
|
+
* whose ledger write died on a CI checkout with no `user.email` produced no
|
|
117
|
+
* text naming git, the ledger, or the identity, on INTENTIUS/choudoufu#1026.
|
|
118
|
+
*/
|
|
91
119
|
export class OpRunFailure extends Error {
|
|
92
|
-
|
|
120
|
+
/** The error the run actually died on, when it was not a step's own failure. */
|
|
121
|
+
public readonly cause?: unknown;
|
|
122
|
+
|
|
123
|
+
constructor(
|
|
124
|
+
public readonly result: OpRunResult,
|
|
125
|
+
options?: { cause?: unknown },
|
|
126
|
+
) {
|
|
93
127
|
super(`Op "${result.op}" failed`);
|
|
94
128
|
this.name = "OpRunFailure";
|
|
129
|
+
// Assigned rather than passed to `super` — the lib this package compiles
|
|
130
|
+
// against predates Error's `options` parameter.
|
|
131
|
+
if (options && "cause" in options) this.cause = options.cause;
|
|
95
132
|
}
|
|
96
133
|
}
|
|
97
134
|
|
|
@@ -109,6 +146,9 @@ class GateStop extends Error {
|
|
|
109
146
|
public readonly records: StepRecord[],
|
|
110
147
|
public readonly pending: PendingGateRecord,
|
|
111
148
|
public readonly phase: string,
|
|
149
|
+
/** Whether this run's own append reached the remote — see {@link OpRunResult.gatePushed}. */
|
|
150
|
+
public readonly pushed?: boolean,
|
|
151
|
+
public readonly pushWarning?: string,
|
|
112
152
|
) {
|
|
113
153
|
super(`gate "${pending.gate}" is pending approval`);
|
|
114
154
|
this.name = "GateStop";
|
|
@@ -118,6 +158,14 @@ class GateStop extends Error {
|
|
|
118
158
|
// ── Helpers ───────────────────────────────────────────────────────────────��─
|
|
119
159
|
|
|
120
160
|
const DEFAULT_PROFILE = "fastIdempotent";
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The phase a synthetic failure record is filed under when the error did not
|
|
164
|
+
* come from a step (#2301) — a ledger write, an abort, a malformed phase.
|
|
165
|
+
* Named rather than blank so a reader of `--json` or of the run ledger can
|
|
166
|
+
* tell it apart from an authored phase.
|
|
167
|
+
*/
|
|
168
|
+
const UNATTRIBUTED_PHASE = "run";
|
|
121
169
|
const FALLBACK_TIMEOUT_MS = 5 * 60_000;
|
|
122
170
|
|
|
123
171
|
const isActivity = (s: StepDefinition): s is ActivityStep => s.kind === "activity";
|
|
@@ -356,16 +404,47 @@ async function runGateStep(
|
|
|
356
404
|
step: GateStep,
|
|
357
405
|
phaseName: string,
|
|
358
406
|
gates: GateContext,
|
|
359
|
-
|
|
407
|
+
resultsById: ReadonlyMap<string, unknown> = new Map(),
|
|
408
|
+
): Promise<{ record: StepRecord; pending?: PendingGateRecord; pushed?: boolean; pushWarning?: string }> {
|
|
360
409
|
const start = Date.now();
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
410
|
+
// #2300: `plan` is authored as a reference into the Plan phase's result
|
|
411
|
+
// (`plan.out.planDigest`), and is resolved here through the same walk an
|
|
412
|
+
// activity's args go through. A reference that resolves to anything but a
|
|
413
|
+
// string leaves the gate unbound — the pre-#2300 rule — rather than failing
|
|
414
|
+
// a run over a missing digest.
|
|
415
|
+
const resolvedPlan = resolveStepOutputRefs(step.plan, resultsById);
|
|
416
|
+
const planDigest = typeof resolvedPlan === "string" && resolvedPlan !== "" ? resolvedPlan : undefined;
|
|
417
|
+
let check: GateCheck;
|
|
418
|
+
try {
|
|
419
|
+
check = await evaluateGate(gates.port, {
|
|
420
|
+
op: gates.op,
|
|
421
|
+
gate: gateName(step),
|
|
422
|
+
...(step.description ? { description: step.description } : {}),
|
|
423
|
+
...(step.timeout ? { timeout: step.timeout } : {}),
|
|
424
|
+
...(gates.runId ? { runId: gates.runId } : {}),
|
|
425
|
+
...(planDigest !== undefined ? { planDigest } : {}),
|
|
426
|
+
...(gates.now ? { now: gates.now } : {}),
|
|
427
|
+
});
|
|
428
|
+
} catch (err) {
|
|
429
|
+
// Deciding a gate reads and writes the `chant/lifecycle` branch, so it
|
|
430
|
+
// fails for all the ordinary git reasons — no committer identity, an
|
|
431
|
+
// unfetched ledger, a rejected ref update. `runStep` has always turned an
|
|
432
|
+
// activity's throw into a failing `StepRecord` carrying the message
|
|
433
|
+
// ("Never throws — returns a record"); `runGateStep` did not, which made
|
|
434
|
+
// the gate the one step kind whose failure produced no record and so no
|
|
435
|
+
// rendered output at all (#2301). It does now, and the phase treats it
|
|
436
|
+
// like any other failing step.
|
|
437
|
+
return {
|
|
438
|
+
record: {
|
|
439
|
+
phase: phaseName,
|
|
440
|
+
fn: gateFn(step),
|
|
441
|
+
args: {},
|
|
442
|
+
status: "fail",
|
|
443
|
+
durationMs: Date.now() - start,
|
|
444
|
+
error: errMessage(err),
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
369
448
|
|
|
370
449
|
if (check.satisfied) {
|
|
371
450
|
const { resolution } = check;
|
|
@@ -386,9 +465,21 @@ async function runGateStep(
|
|
|
386
465
|
};
|
|
387
466
|
}
|
|
388
467
|
|
|
468
|
+
const refusal = check.mismatch
|
|
469
|
+
? { refusal: describeGateMismatch(gates.op, gateName(step), check.mismatch) }
|
|
470
|
+
: {};
|
|
389
471
|
return {
|
|
390
|
-
record: {
|
|
472
|
+
record: {
|
|
473
|
+
phase: phaseName,
|
|
474
|
+
fn: gateFn(step),
|
|
475
|
+
args: {},
|
|
476
|
+
status: "skipped",
|
|
477
|
+
durationMs: Date.now() - start,
|
|
478
|
+
...refusal,
|
|
479
|
+
},
|
|
391
480
|
pending: check.pending,
|
|
481
|
+
pushed: check.pushed,
|
|
482
|
+
...(check.pushWarning ? { pushWarning: check.pushWarning } : {}),
|
|
392
483
|
};
|
|
393
484
|
}
|
|
394
485
|
|
|
@@ -413,7 +504,13 @@ async function runEffectStep(
|
|
|
413
504
|
resultsById: Map<string, unknown>,
|
|
414
505
|
gates: GateContext,
|
|
415
506
|
signal?: AbortSignal,
|
|
416
|
-
): Promise<{
|
|
507
|
+
): Promise<{
|
|
508
|
+
records: StepRecord[];
|
|
509
|
+
failed: boolean;
|
|
510
|
+
pending?: PendingGateRecord;
|
|
511
|
+
pushed?: boolean;
|
|
512
|
+
pushWarning?: string;
|
|
513
|
+
}> {
|
|
417
514
|
const records: StepRecord[] = [];
|
|
418
515
|
|
|
419
516
|
const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, resultsById, signal);
|
|
@@ -460,13 +557,19 @@ async function runEffectStep(
|
|
|
460
557
|
for (let i = 0; i < step.steps.length; i++) {
|
|
461
558
|
const nested = step.steps[i];
|
|
462
559
|
if (isGate(nested)) {
|
|
463
|
-
const { record, pending } = await runGateStep(nested, phaseName, gates);
|
|
560
|
+
const { record, pending, pushed, pushWarning } = await runGateStep(nested, phaseName, gates, resultsById);
|
|
464
561
|
pushRecord(records, gates, record);
|
|
562
|
+
if (record.status === "fail") {
|
|
563
|
+
// Receipt left untouched, as for any other failing nested step
|
|
564
|
+
// (#2301) — the effect is not applied and the next run re-proposes it.
|
|
565
|
+
skipRest(i + 1);
|
|
566
|
+
return { records, failed: true };
|
|
567
|
+
}
|
|
465
568
|
if (pending) {
|
|
466
569
|
// Receipt left untouched — the next run re-proposes the effect and
|
|
467
570
|
// re-evaluates the gate against whatever the ledger says by then.
|
|
468
571
|
skipRest(i + 1);
|
|
469
|
-
return { records, failed: false, pending };
|
|
572
|
+
return { records, failed: false, pending, pushed, ...(pushWarning ? { pushWarning } : {}) };
|
|
470
573
|
}
|
|
471
574
|
continue;
|
|
472
575
|
}
|
|
@@ -521,13 +624,21 @@ async function runPhase(
|
|
|
521
624
|
// gate is about to strand would defeat the point of stopping at it.
|
|
522
625
|
const gateRecords: StepRecord[] = [];
|
|
523
626
|
for (const step of phase.steps.filter(isGate)) {
|
|
524
|
-
const { record, pending } = await runGateStep(step, phase.name, gates);
|
|
627
|
+
const { record, pending, pushed, pushWarning } = await runGateStep(step, phase.name, gates, resultsById);
|
|
525
628
|
pushRecord(gateRecords, gates, record);
|
|
629
|
+
if (record.status === "fail") {
|
|
630
|
+
// The gate could not be decided at all (#2301). Same treatment as a
|
|
631
|
+
// pending one: nothing fans out, the activities are recorded skipped.
|
|
632
|
+
for (const skipped of phase.steps.filter(isActivity)) {
|
|
633
|
+
pushRecord(gateRecords, gates, skippedRecord(phase.name, skipped.fn, skipped.args));
|
|
634
|
+
}
|
|
635
|
+
throw new PhaseFailure(gateRecords);
|
|
636
|
+
}
|
|
526
637
|
if (pending) {
|
|
527
638
|
for (const skipped of phase.steps.filter(isActivity)) {
|
|
528
639
|
pushRecord(gateRecords, gates, skippedRecord(phase.name, skipped.fn, skipped.args));
|
|
529
640
|
}
|
|
530
|
-
throw new GateStop(gateRecords, pending, phase.name);
|
|
641
|
+
throw new GateStop(gateRecords, pending, phase.name, pushed, pushWarning);
|
|
531
642
|
}
|
|
532
643
|
}
|
|
533
644
|
const steps = phase.steps.filter(isActivity);
|
|
@@ -558,22 +669,28 @@ async function runPhase(
|
|
|
558
669
|
for (let i = 0; i < steps.length; i++) {
|
|
559
670
|
const step = steps[i];
|
|
560
671
|
if (isGate(step)) {
|
|
561
|
-
const { record, pending } = await runGateStep(step, phase.name, gates);
|
|
672
|
+
const { record, pending, pushed, pushWarning } = await runGateStep(step, phase.name, gates, resultsById);
|
|
562
673
|
pushRecord(records, gates, record);
|
|
674
|
+
if (record.status === "fail") {
|
|
675
|
+
// A gate that could not be decided is a failed step, not a pending
|
|
676
|
+
// one (#2301) — the run failed, and the record says why.
|
|
677
|
+
skipRemaining(i + 1);
|
|
678
|
+
throw new PhaseFailure(records);
|
|
679
|
+
}
|
|
563
680
|
if (pending) {
|
|
564
681
|
skipRemaining(i + 1);
|
|
565
|
-
throw new GateStop(records, pending, phase.name);
|
|
682
|
+
throw new GateStop(records, pending, phase.name, pushed, pushWarning);
|
|
566
683
|
}
|
|
567
684
|
continue;
|
|
568
685
|
}
|
|
569
686
|
if (isEffect(step)) {
|
|
570
|
-
const { records: effRecords, failed, pending } = await runEffectStep(
|
|
687
|
+
const { records: effRecords, failed, pending, pushed, pushWarning } = await runEffectStep(
|
|
571
688
|
step, phase.name, activities, profiles, resultsById, gates, signal,
|
|
572
689
|
);
|
|
573
690
|
records.push(...effRecords); // already emitted by runEffectStep
|
|
574
691
|
if (pending) {
|
|
575
692
|
skipRemaining(i + 1);
|
|
576
|
-
throw new GateStop(records, pending, phase.name);
|
|
693
|
+
throw new GateStop(records, pending, phase.name, pushed, pushWarning);
|
|
577
694
|
}
|
|
578
695
|
if (failed) {
|
|
579
696
|
skipRemaining(i + 1);
|
|
@@ -743,11 +860,36 @@ export async function runOpLocally(
|
|
|
743
860
|
status: "gated",
|
|
744
861
|
startedAt,
|
|
745
862
|
gate: err.pending,
|
|
863
|
+
...(err.pushed !== undefined ? { gatePushed: err.pushed } : {}),
|
|
864
|
+
...(err.pushWarning ? { gatePushWarning: err.pushWarning } : {}),
|
|
746
865
|
record: await settle(records, "gated", err.pending),
|
|
747
866
|
};
|
|
748
867
|
}
|
|
749
868
|
|
|
750
|
-
if (err instanceof PhaseFailure)
|
|
869
|
+
if (err instanceof PhaseFailure) {
|
|
870
|
+
records.push(...err.records);
|
|
871
|
+
} else {
|
|
872
|
+
// Everything else the phase loop can throw (#2301). `PhaseFailure` and
|
|
873
|
+
// `GateStop` are the two shapes that carry their own records; anything
|
|
874
|
+
// else — a git failure inside a ledger write, a bad phase definition, a
|
|
875
|
+
// bug — used to be dropped here without being read, leaving a result
|
|
876
|
+
// whose `records` hold nothing marked failed and nothing carrying a
|
|
877
|
+
// message. The renderer prints text only from records, so the operator
|
|
878
|
+
// got `Op "x" failed after 43.8s` and no cause at all.
|
|
879
|
+
//
|
|
880
|
+
// A synthetic record is what makes it visible: it renders as a failing
|
|
881
|
+
// step like any other, lands in the run ledger, and appears in
|
|
882
|
+
// `--json`. `cause` on the thrown `OpRunFailure` carries the original
|
|
883
|
+
// error for a caller that wants the object rather than the text.
|
|
884
|
+
records.push({
|
|
885
|
+
phase: UNATTRIBUTED_PHASE,
|
|
886
|
+
fn: "opFailure",
|
|
887
|
+
args: {},
|
|
888
|
+
status: "fail",
|
|
889
|
+
durationMs: 0,
|
|
890
|
+
error: errMessage(err),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
751
893
|
|
|
752
894
|
// Compensation: run onFailure phases in reverse order (best-effort). Skipped
|
|
753
895
|
// on abort (Ctrl-C) — the user asked to stop, so don't start new work.
|
|
@@ -756,19 +898,35 @@ export async function runOpLocally(
|
|
|
756
898
|
try {
|
|
757
899
|
records.push(...(await runPhase(phase, activities, profiles, resultsById, gates, signal)));
|
|
758
900
|
} catch (compErr) {
|
|
759
|
-
if (compErr instanceof PhaseFailure || compErr instanceof GateStop)
|
|
901
|
+
if (compErr instanceof PhaseFailure || compErr instanceof GateStop) {
|
|
902
|
+
records.push(...compErr.records);
|
|
903
|
+
} else {
|
|
904
|
+
// The same swallow, one level down (#2301): a compensation phase
|
|
905
|
+
// that threw something else left no trace whatsoever.
|
|
906
|
+
records.push({
|
|
907
|
+
phase: phase.name,
|
|
908
|
+
fn: "onFailure",
|
|
909
|
+
args: {},
|
|
910
|
+
status: "fail",
|
|
911
|
+
durationMs: 0,
|
|
912
|
+
error: errMessage(compErr),
|
|
913
|
+
});
|
|
914
|
+
}
|
|
760
915
|
}
|
|
761
916
|
}
|
|
762
917
|
}
|
|
763
918
|
|
|
764
|
-
throw new OpRunFailure(
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
919
|
+
throw new OpRunFailure(
|
|
920
|
+
{
|
|
921
|
+
op: config.name,
|
|
922
|
+
records,
|
|
923
|
+
totalMs: Date.now() - start,
|
|
924
|
+
status: "fail",
|
|
925
|
+
startedAt,
|
|
926
|
+
record: await settle(records, "fail"),
|
|
927
|
+
},
|
|
928
|
+
{ cause: err },
|
|
929
|
+
);
|
|
772
930
|
}
|
|
773
931
|
|
|
774
932
|
return {
|
|
@@ -101,6 +101,27 @@ describe("renderHuman — gated (#2119)", () => {
|
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
describe("renderHuman — a gated run whose own push never reached the remote (#2310)", () => {
|
|
105
|
+
test("warns that an operator elsewhere cannot see the pending fact, rather than staying silent", () => {
|
|
106
|
+
const lines: string[] = [];
|
|
107
|
+
renderHuman(
|
|
108
|
+
{ ...GATED, gatePushed: false, gatePushWarning: "chant/lifecycle remote branch has moved since this run started" },
|
|
109
|
+
(l) => lines.push(l),
|
|
110
|
+
);
|
|
111
|
+
const out = lines.join("\n");
|
|
112
|
+
expect(out).toContain('Op "prod-apply" is gated on "rollout-gate"');
|
|
113
|
+
expect(out).toContain("was not pushed to the remote");
|
|
114
|
+
expect(out).toContain("chant/lifecycle remote branch has moved since this run started");
|
|
115
|
+
expect(out).toContain("An operator elsewhere cannot approve it");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("stays as it was when the push landed", () => {
|
|
119
|
+
const lines: string[] = [];
|
|
120
|
+
renderHuman({ ...GATED, gatePushed: true }, (l) => lines.push(l));
|
|
121
|
+
expect(lines.join("\n")).not.toContain("was not pushed");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
104
125
|
describe("renderJson", () => {
|
|
105
126
|
test("prints the run's ledger record, not the raw result (#2118)", () => {
|
|
106
127
|
const lines: string[] = [];
|
|
@@ -135,4 +156,21 @@ describe("renderJson", () => {
|
|
|
135
156
|
expect(parsed.gate).toEqual({ name: "rollout-gate", since: "2026-09-05T12:00:00.000Z" });
|
|
136
157
|
expect(parsed.approve).toBe("chant approve prod-apply rollout-gate");
|
|
137
158
|
});
|
|
159
|
+
|
|
160
|
+
// #2310: a JSON consumer (CI tooling reading `chant run --json`) needs the
|
|
161
|
+
// same fact the human render shows — not just a silent success.
|
|
162
|
+
test("a gated record whose push failed carries pushed:false and why", () => {
|
|
163
|
+
const lines: string[] = [];
|
|
164
|
+
renderJson({ ...GATED, gatePushed: false, gatePushWarning: "no remote is configured" }, (l) => lines.push(l));
|
|
165
|
+
const parsed = JSON.parse(lines[0]) as { pushed?: boolean; pushWarning?: string };
|
|
166
|
+
expect(parsed.pushed).toBe(false);
|
|
167
|
+
expect(parsed.pushWarning).toBe("no remote is configured");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a gated record whose push landed carries no pushed field", () => {
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
renderJson({ ...GATED, gatePushed: true }, (l) => lines.push(l));
|
|
173
|
+
const parsed = JSON.parse(lines[0]) as { pushed?: boolean };
|
|
174
|
+
expect(parsed.pushed).toBeUndefined();
|
|
175
|
+
});
|
|
138
176
|
});
|
package/src/op/local-output.ts
CHANGED
|
@@ -47,6 +47,9 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
|
|
|
47
47
|
write(` [approved] ${record.approval.resolvedBy} at ${record.approval.timestamp}` +
|
|
48
48
|
(record.approval.url ? ` (${record.approval.url})` : ""));
|
|
49
49
|
}
|
|
50
|
+
if (record.refusal) {
|
|
51
|
+
write(` [refused] ${record.refusal}`);
|
|
52
|
+
}
|
|
50
53
|
if (record.error) {
|
|
51
54
|
write(` ${record.error}`);
|
|
52
55
|
}
|
|
@@ -68,9 +71,23 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
|
|
|
68
71
|
const { gate } = result;
|
|
69
72
|
write(`Op "${result.op}" is gated on "${gate.gate}" after ${total}`);
|
|
70
73
|
if (gate.description) write(` ${gate.description}`);
|
|
74
|
+
// #2300: the plan the approval will be bound to. Printed before the
|
|
75
|
+
// command, because it is what the command approves.
|
|
76
|
+
if (gate.planDigest) write(` plan : ${gate.planDigest}`);
|
|
71
77
|
write(` approve : ${approveCommand(gate.op, gate.gate)}`);
|
|
72
78
|
if (gate.url) write(` approve at: ${gate.url}`);
|
|
73
79
|
write(` expires : ${gate.expiresAt}`);
|
|
80
|
+
// #2310: this run's own append reached only the local chant/lifecycle
|
|
81
|
+
// branch, not the remote. The pending fact is still correct — the gate is
|
|
82
|
+
// still right to stand — but an operator working from a clone of the
|
|
83
|
+
// remote cannot see it to approve it, and nothing else here says so.
|
|
84
|
+
if (result.gatePushed === false) {
|
|
85
|
+
write(
|
|
86
|
+
` warning : the pending fact was not pushed to the remote — ` +
|
|
87
|
+
(result.gatePushWarning ?? "it exists only in this checkout") +
|
|
88
|
+
`. An operator elsewhere cannot approve it until it does.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
/**
|
|
@@ -86,7 +103,13 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
|
|
|
86
103
|
*/
|
|
87
104
|
export function renderJson(result: OpRunResult, write: Writer = stdout): void {
|
|
88
105
|
const approve = result.gate ? { approve: approveCommand(result.gate.op, result.gate.gate) } : {};
|
|
89
|
-
|
|
106
|
+
// #2310: whether this run's own append reached the remote — not part of
|
|
107
|
+
// the persisted ledger record (a replay has nothing new to report), but a
|
|
108
|
+
// live run's JSON consumer needs it exactly where the human render shows it.
|
|
109
|
+
const push = result.gatePushed === false
|
|
110
|
+
? { pushed: false, pushWarning: result.gatePushWarning ?? "the pending fact was recorded locally only" }
|
|
111
|
+
: {};
|
|
112
|
+
write(JSON.stringify({ ...result.record, ...approve, ...push }));
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
export type { OpRunResult, StepRecord };
|
package/src/op/op-ir.test.ts
CHANGED
|
@@ -222,6 +222,28 @@ describe("op.json IR", () => {
|
|
|
222
222
|
expect(reconstructed.labels).toEqual({ Team: "infra", Env: "staging" });
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
// #2300 — a gate can bind a plan by referencing the Plan step's digest, and
|
|
226
|
+
// the reference has to survive the IR: a foreign runtime resolves it the
|
|
227
|
+
// same way it resolves one in an activity's args.
|
|
228
|
+
it("carries a gate's plan reference through op.json and back", () => {
|
|
229
|
+
const config: OpConfig = {
|
|
230
|
+
name: "live-apply",
|
|
231
|
+
overview: "o",
|
|
232
|
+
phases: [
|
|
233
|
+
phase("Plan", [{ kind: "activity", fn: "shellCmd", id: "plan", args: { cmd: "plan" } }]),
|
|
234
|
+
phase("Gate", [{ kind: "gate", gate: "approve-live-apply", plan: stepOutput("plan", "planDigest") }]),
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
const text = serializeOpIR(config);
|
|
238
|
+
const ir = JSON.parse(text) as OpIR;
|
|
239
|
+
const gate = ir.phases[1].steps[0];
|
|
240
|
+
expect(gate.kind).toBe("gate");
|
|
241
|
+
expect((gate as { plan?: unknown }).plan).toMatchObject({ step: "plan", path: "planDigest" });
|
|
242
|
+
|
|
243
|
+
const reconstructed = opConfigFromIR(ir);
|
|
244
|
+
expect(serializeOpIR(reconstructed)).toBe(text);
|
|
245
|
+
});
|
|
246
|
+
|
|
225
247
|
it("round-trips a minimal Op (no gate, effect, onFailure or labels) too", () => {
|
|
226
248
|
const original: OpConfig = { name: "minimal", overview: "o", phases: [phase("Only", [shell("echo hi")])] };
|
|
227
249
|
const text = serializeOpIR(original);
|
package/src/op/op-ir.ts
CHANGED
|
@@ -137,6 +137,13 @@ export interface OpIRGateStep {
|
|
|
137
137
|
/** Resolved to its effective value — `GateStep.timeout ?? "48h"`. */
|
|
138
138
|
timeout: string;
|
|
139
139
|
description?: string;
|
|
140
|
+
/**
|
|
141
|
+
* The plan this gate approves (#2300) — a digest string, or a
|
|
142
|
+
* {@link StepOutputRef} placeholder a foreign runtime resolves from the
|
|
143
|
+
* named step's result the same way it resolves one in an activity's args.
|
|
144
|
+
* Absent on a gate that binds no plan.
|
|
145
|
+
*/
|
|
146
|
+
plan?: GateStep["plan"];
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
export interface OpIREffectStep {
|
|
@@ -232,6 +239,7 @@ function irGateStep(step: GateStep): OpIRGateStep {
|
|
|
232
239
|
gate: gateName(step),
|
|
233
240
|
timeout: step.timeout ?? "48h",
|
|
234
241
|
...(step.description ? { description: step.description } : {}),
|
|
242
|
+
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
235
243
|
};
|
|
236
244
|
}
|
|
237
245
|
|
|
@@ -380,6 +388,7 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
|
|
|
380
388
|
gate: step.gate,
|
|
381
389
|
timeout: step.timeout,
|
|
382
390
|
...(step.description ? { description: step.description } : {}),
|
|
391
|
+
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
383
392
|
};
|
|
384
393
|
}
|
|
385
394
|
return {
|
package/src/op/operator.test.ts
CHANGED
|
@@ -293,6 +293,26 @@ describe("runOperatorRound — lease + tick execution over a fixture ConvergeOp
|
|
|
293
293
|
});
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
+
/**
|
|
297
|
+
* #2309 review, finding 3. `chant operator` renders its own events rather
|
|
298
|
+
* than going through `renderHuman`, so fixing the executor's swallow (#2301)
|
|
299
|
+
* did nothing for this path: the message was the fixed string "see its
|
|
300
|
+
* ledger record for step-level detail", which sends the reader to an
|
|
301
|
+
* artifact that a failed ledger append means is not there.
|
|
302
|
+
*/
|
|
303
|
+
test("a failing tick names the failing step, not just the ledger record", async () => {
|
|
304
|
+
await withTestDir(async (dir) => {
|
|
305
|
+
await initRepo(dir);
|
|
306
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
307
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge", { throws: true });
|
|
308
|
+
|
|
309
|
+
const events = await runOperatorRound({ cwd: dir, holder: "op-a", activities, profiles: PROFILES });
|
|
310
|
+
const failed = events[0] as Extract<OperatorTickEvent, { kind: "tick-failed" }>;
|
|
311
|
+
expect(failed.error).not.toMatch(/see its ledger record/);
|
|
312
|
+
expect(failed.error).toMatch(/^Op "staging-converge" failed: /);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
296
316
|
test("ticks every discovered ConvergeOp across environments in one round", async () => {
|
|
297
317
|
await withTestDir(async (dir) => {
|
|
298
318
|
await initRepo(dir);
|
package/src/op/operator.ts
CHANGED
|
@@ -180,13 +180,31 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
|
|
|
180
180
|
try {
|
|
181
181
|
const result = await runOpLocally(config, opts.activities, opts.profiles, opts.signal, {
|
|
182
182
|
ledger: { cwd: opts.cwd },
|
|
183
|
+
// #2301: without a sink, `settle` catches a failed ledger append and
|
|
184
|
+
// drops it. That is the failure this tick can least afford to lose —
|
|
185
|
+
// the message below points the reader at the ledger record, which is
|
|
186
|
+
// exactly the artifact a failed append means is not there.
|
|
187
|
+
onLedgerError: (err) =>
|
|
188
|
+
events.push({
|
|
189
|
+
kind: "tick-failed",
|
|
190
|
+
op: config.name,
|
|
191
|
+
env,
|
|
192
|
+
error:
|
|
193
|
+
`Op "${config.name}" ran, but its record could not be appended to the run ledger: ` +
|
|
194
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
195
|
+
}),
|
|
183
196
|
});
|
|
184
197
|
const held = await stillHoldsLease(config.name, holder, lease.token, { cwd: opts.cwd });
|
|
185
198
|
events.push(held ? { kind: "ticked", op: config.name, env, result } : { kind: "fenced", op: config.name, env });
|
|
186
199
|
} catch (err) {
|
|
200
|
+
// #2301: "see its ledger record" was the whole message, and it sent the
|
|
201
|
+
// reader to an artifact that a failed ledger append means is missing —
|
|
202
|
+
// while `err.result` was carrying the failing steps all along. Name the
|
|
203
|
+
// failing steps here, and fall back to `cause` for a failure that
|
|
204
|
+
// produced no step record at all.
|
|
187
205
|
const message =
|
|
188
206
|
err instanceof OpRunFailure
|
|
189
|
-
?
|
|
207
|
+
? failureDetail(config.name, err)
|
|
190
208
|
: err instanceof Error
|
|
191
209
|
? err.message
|
|
192
210
|
: String(err);
|
|
@@ -197,6 +215,26 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
|
|
|
197
215
|
return events;
|
|
198
216
|
}
|
|
199
217
|
|
|
218
|
+
/**
|
|
219
|
+
* What a failed tick says about itself (#2301).
|
|
220
|
+
*
|
|
221
|
+
* `chant operator` renders its own events rather than going through
|
|
222
|
+
* `renderHuman`, so the executor's step records reached nobody here even after
|
|
223
|
+
* the executor stopped dropping them. Every failing step's message, in order,
|
|
224
|
+
* or the underlying error when the run failed without producing one.
|
|
225
|
+
*/
|
|
226
|
+
function failureDetail(op: string, err: OpRunFailure): string {
|
|
227
|
+
const failed = err.result.records.filter((r) => r.status === "fail" && r.error);
|
|
228
|
+
if (failed.length > 0) {
|
|
229
|
+
return `Op "${op}" failed: ${failed.map((r) => `${r.fn}: ${r.error}`).join("; ")}`;
|
|
230
|
+
}
|
|
231
|
+
const cause = err.cause;
|
|
232
|
+
if (cause !== undefined) {
|
|
233
|
+
return `Op "${op}" failed: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
234
|
+
}
|
|
235
|
+
return `Op "${op}" failed — see its ledger record for step-level detail`;
|
|
236
|
+
}
|
|
237
|
+
|
|
200
238
|
/** Abortable sleep — resolves early (without throwing) if `signal` fires mid-wait, so the operator loop can stop promptly on Ctrl-C rather than finishing out a long interval. */
|
|
201
239
|
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
202
240
|
return new Promise((resolve) => {
|
package/src/op/runtime.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface OpRunStepRecord {
|
|
|
49
49
|
approval?: { gate: string; resolvedBy: string; timestamp: string; url?: string };
|
|
50
50
|
/** The failure message, for `status: "fail"`. */
|
|
51
51
|
error?: string;
|
|
52
|
+
/** Why a gate declined a standing approval that was for another plan (#2300) — see {@link StepRecord.refusal}. */
|
|
53
|
+
refusal?: string;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/** One phase's steps and the verdict they add up to. */
|
package/src/op/runtimes/local.ts
CHANGED
|
@@ -153,6 +153,17 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
153
153
|
// appends it here, at the one seam every local run passes
|
|
154
154
|
// through, rather than in the CLI handler above it.
|
|
155
155
|
ledger: { cwd: projectPath },
|
|
156
|
+
// #2301: the run-ledger append goes through the same
|
|
157
|
+
// `chant/lifecycle` write as the gate does, so it dies for the
|
|
158
|
+
// same reasons — and this runtime declared no sink for it, so
|
|
159
|
+
// the failure was caught by `settle` and dropped on the floor.
|
|
160
|
+
// A run whose outcome never reached the ledger says so now;
|
|
161
|
+
// `chant run status` and `chant run log` will not have it.
|
|
162
|
+
onLedgerError: (err) =>
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
`warning: run "${runId}" of "${op.name}" finished, but its record could not be ` +
|
|
165
|
+
`appended to the run ledger: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
166
|
+
),
|
|
156
167
|
...(startOpts.progress ? { onRecord: startOpts.progress } : {}),
|
|
157
168
|
},
|
|
158
169
|
);
|
package/src/op/types.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { EffectReceiptRef } from "./receipt-store";
|
|
9
9
|
import type { ActivityProfileName } from "./activity-profiles";
|
|
10
|
+
import type { StepOutputRef } from "./step-output-ref";
|
|
10
11
|
|
|
11
12
|
export interface OpConfig {
|
|
12
13
|
/** Kebab-case identifier. Names the Op's output directory (`dist/ops/<name>/`), and is the name `chant run <name>` and another Op's `depends` refer to. */
|
|
@@ -177,6 +178,24 @@ export interface GateStepBase {
|
|
|
177
178
|
timeout?: string;
|
|
178
179
|
/** Human-readable description of the action required to unblock this gate. */
|
|
179
180
|
description?: string;
|
|
181
|
+
/**
|
|
182
|
+
* The plan this gate approves (#2300), normally a {@link StepOutputRef}
|
|
183
|
+
* into the Plan phase's own digest — `plan.out.planDigest`. The executor
|
|
184
|
+
* resolves it the same way it resolves an activity step's args, and hands
|
|
185
|
+
* the result to `evaluateGate` as `planDigest`.
|
|
186
|
+
*
|
|
187
|
+
* With it, a resolution satisfies this gate only when it was recorded for
|
|
188
|
+
* that exact plan; a resolution for another plan is refused by name.
|
|
189
|
+
* Without it the gate authorises the next run rather than a plan, which is
|
|
190
|
+
* what every gate did before #2300 and what
|
|
191
|
+
* INTENTIUS/choudoufu#1026 measured.
|
|
192
|
+
*
|
|
193
|
+
* A `string` here is a digest computed elsewhere; anything else is a
|
|
194
|
+
* reference resolved at run time. A reference that resolves to no string
|
|
195
|
+
* leaves the gate unbound rather than failing the run — an Op whose Plan
|
|
196
|
+
* phase publishes no digest is not thereby unrunnable.
|
|
197
|
+
*/
|
|
198
|
+
plan?: string | StepOutputRef;
|
|
180
199
|
}
|
|
181
200
|
|
|
182
201
|
/**
|