@intentius/chant 0.124.23 → 0.124.25
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/ci-jobs.d.ts +41 -1
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +48 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +14 -0
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +9 -0
- package/dist/cli/handlers/emulator.d.ts +13 -2
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.js +63 -3
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +7 -1
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.js +5 -4
- package/dist/cli/handlers/run.d.ts +4 -3
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +73 -9
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +38 -11
- 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/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +31 -0
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +45 -6
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +97 -22
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +25 -10
- 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/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +24 -2
- 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/git.d.ts +11 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/git.js +62 -6
- package/dist/lifecycle/no-record.d.ts +59 -0
- package/dist/lifecycle/no-record.d.ts.map +1 -0
- package/dist/lifecycle/no-record.js +88 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.js +4 -2
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +30 -9
- package/dist/op/emulator-lifecycle.d.ts +23 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.js +39 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +3 -0
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +1 -1
- package/dist/op/local-executor.d.ts +10 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +71 -7
- package/dist/op/op-waves-notify.d.ts +2 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-notify.js +9 -3
- package/dist/op/op-waves-run.d.ts +8 -2
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +36 -11
- package/dist/op/op-waves.d.ts +85 -1
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +153 -1
- 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/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +6 -0
- package/dist/op/step-console.d.ts +65 -0
- package/dist/op/step-console.d.ts.map +1 -0
- package/dist/op/step-console.js +140 -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/point-decision.d.ts.map +1 -1
- package/dist/point-decision.js +3 -0
- package/dist/wave-runtime.mjs +239 -239
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/agent-cli.js +3 -0
- package/dist/workspace/compose-graph.d.ts +22 -3
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +18 -5
- 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/graph.schema.json +68 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +20 -11
- package/dist/workspace/nested-graph.d.ts +1 -1
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +5 -1
- 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/runs.d.ts.map +1 -1
- package/dist/workspace/runs.js +4 -2
- 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/ci-jobs.ts +58 -1
- package/src/cli/commands/build.ts +18 -0
- package/src/cli/compiled-cli.e2e.test.ts +45 -1
- package/src/cli/handlers/approve.ts +9 -0
- package/src/cli/handlers/emulator.test.ts +61 -1
- package/src/cli/handlers/emulator.ts +65 -4
- package/src/cli/handlers/promote.test.ts +7 -0
- package/src/cli/handlers/promote.ts +7 -1
- package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
- package/src/cli/handlers/run-wave.ts +5 -4
- package/src/cli/handlers/run.test.ts +126 -0
- package/src/cli/handlers/run.ts +69 -9
- package/src/cli/main.test.ts +34 -0
- package/src/cli/main.ts +38 -11
- package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +27 -4
- package/src/cli/parse-args.ts +21 -0
- package/src/cli/registry.ts +12 -0
- package/src/components/cli-support.test.ts +75 -0
- package/src/components/cli-support.ts +130 -25
- package/src/components/driver.test.ts +28 -0
- package/src/components/driver.ts +23 -10
- package/src/components/generate-disabled.test.ts +56 -0
- package/src/effect-receipt.ts +22 -0
- package/src/graph-ci-edges.test.ts +50 -0
- package/src/graph-detail.ts +21 -2
- package/src/lexicon-module.ts +9 -2
- package/src/lifecycle/git.ts +61 -6
- package/src/lifecycle/no-record.test.ts +175 -0
- package/src/lifecycle/no-record.ts +108 -0
- package/src/lifecycle/receipt-plan.test.ts +9 -0
- package/src/lifecycle/receipt-plan.ts +4 -1
- package/src/op/activities/shell.ts +28 -6
- package/src/op/effect-step.test.ts +95 -1
- package/src/op/emulator-lifecycle.test.ts +24 -1
- package/src/op/emulator-lifecycle.ts +58 -2
- package/src/op/gate.ts +3 -0
- package/src/op/index.ts +1 -1
- package/src/op/local-executor.test.ts +65 -0
- package/src/op/local-executor.ts +88 -8
- package/src/op/op-waves-jobs.test.ts +213 -0
- package/src/op/op-waves-notify.ts +11 -5
- package/src/op/op-waves-run.ts +42 -11
- package/src/op/op-waves.ts +202 -2
- package/src/op/receipt-store.ts +13 -3
- package/src/op/runtimes/local.ts +6 -0
- package/src/op/step-console.test.ts +150 -0
- package/src/op/step-console.ts +155 -0
- package/src/op/step-output-ref.test.ts +68 -24
- package/src/op/step-output-ref.ts +85 -65
- package/src/point-decision-bundle.test.ts +1 -0
- package/src/point-decision.ts +3 -0
- package/src/workspace/agent-cli.ts +2 -0
- package/src/workspace/compose-graph.test.ts +55 -0
- package/src/workspace/compose-graph.ts +28 -6
- 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/graph-contract.test.ts +26 -1
- package/src/workspace/graph.schema.json +68 -1
- package/src/workspace/lineage-upgrade.test.ts +21 -0
- package/src/workspace/lineage-upgrade.ts +19 -11
- package/src/workspace/nested-graph.test.ts +14 -0
- package/src/workspace/nested-graph.ts +7 -2
- 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/runs.ts +4 -2
- 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
|
@@ -3,6 +3,7 @@ import { promisify } from "node:util";
|
|
|
3
3
|
import { GateWait, asPendingGate } from "../gate-wait";
|
|
4
4
|
import type { PendingGateRecord } from "../../lifecycle/gate-ledger";
|
|
5
5
|
import { liveRunEnv } from "../run-live";
|
|
6
|
+
import { hasStepConsole, stepLineSplitter } from "../step-console";
|
|
6
7
|
|
|
7
8
|
const execAsync = promisify(exec);
|
|
8
9
|
|
|
@@ -114,8 +115,17 @@ interface ExecFailure extends Error {
|
|
|
114
115
|
*/
|
|
115
116
|
export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promise<ShellCmdResult> {
|
|
116
117
|
const okExit = args.okExit ?? [0];
|
|
118
|
+
// #3956: under a step console (../step-console.ts) every line the command
|
|
119
|
+
// writes, stdout and stderr, is printed as it arrives; the result still
|
|
120
|
+
// carries both whole. Without one, stderr is printed when the command ends.
|
|
121
|
+
const streamed = hasStepConsole();
|
|
122
|
+
const printStderr = (text: string) => {
|
|
123
|
+
if (text && !streamed) console.error(text);
|
|
124
|
+
};
|
|
125
|
+
const out = stepLineSplitter();
|
|
126
|
+
const err = stepLineSplitter();
|
|
117
127
|
try {
|
|
118
|
-
const
|
|
128
|
+
const running = execAsync(args.cmd, {
|
|
119
129
|
cwd: args.cwd,
|
|
120
130
|
// In a run that keeps an in-flight record, CHANT_RUN_ACTIVITY names the
|
|
121
131
|
// file the command appends activity lines to (../run-live.ts).
|
|
@@ -123,10 +133,22 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
|
|
|
123
133
|
maxBuffer: MAX_STDOUT_BYTES,
|
|
124
134
|
signal,
|
|
125
135
|
});
|
|
126
|
-
if (
|
|
136
|
+
if (streamed) {
|
|
137
|
+
running.child.stdout?.on("data", (chunk: string | Buffer) => out.push(chunk));
|
|
138
|
+
running.child.stderr?.on("data", (chunk: string | Buffer) => err.push(chunk));
|
|
139
|
+
}
|
|
140
|
+
let settled: { stdout: string; stderr: string };
|
|
141
|
+
try {
|
|
142
|
+
settled = await running;
|
|
143
|
+
} finally {
|
|
144
|
+
out.end();
|
|
145
|
+
err.end();
|
|
146
|
+
}
|
|
147
|
+
const { stdout, stderr } = settled;
|
|
148
|
+
printStderr(stderr);
|
|
127
149
|
return withJson({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }, args);
|
|
128
|
-
} catch (
|
|
129
|
-
const failure =
|
|
150
|
+
} catch (caught) {
|
|
151
|
+
const failure = caught as ExecFailure;
|
|
130
152
|
const exitCode = typeof failure.code === "number" ? failure.code : undefined;
|
|
131
153
|
// A signal kill (Ctrl-C, the profile's timeout, a maxBuffer overflow) is
|
|
132
154
|
// not an exit status the author declared anything about, so it rethrows
|
|
@@ -134,12 +156,12 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
|
|
|
134
156
|
const died = failure.killed === true || typeof failure.signal === "string" || signal?.aborted === true;
|
|
135
157
|
if (exitCode !== undefined && !died && args.gatedExit !== undefined && exitCode === args.gatedExit) {
|
|
136
158
|
const stderrText = (failure.stderr ?? "").trim();
|
|
137
|
-
|
|
159
|
+
printStderr(stderrText);
|
|
138
160
|
throw new GateWait(await commandGate(args, (failure.stdout ?? "").trim(), exitCode));
|
|
139
161
|
}
|
|
140
162
|
if (exitCode !== undefined && !died && okExit.includes(exitCode)) {
|
|
141
163
|
const stderrText = (failure.stderr ?? "").trim();
|
|
142
|
-
|
|
164
|
+
printStderr(stderrText);
|
|
143
165
|
return withJson({ stdout: (failure.stdout ?? "").trim(), stderr: stderrText, exitCode }, args);
|
|
144
166
|
}
|
|
145
167
|
if (exitCode !== undefined && !died) {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* differing receipts as findings and never writes.
|
|
13
13
|
*/
|
|
14
14
|
import { describe, test, expect, vi } from "vitest";
|
|
15
|
-
import { EffectReceipt, receiptExpectation, EXISTENCE_EXPECTATION } from "../effect-receipt";
|
|
15
|
+
import { EffectReceipt, receiptExpectation, EXISTENCE_EXPECTATION, declaredExpectation, withStepOutputs } from "../effect-receipt";
|
|
16
|
+
import { stepOutput } from "./step-output-ref";
|
|
16
17
|
import { INTRINSIC_MARKER, type Intrinsic } from "../intrinsic";
|
|
17
18
|
import { effect, phase } from "./builders";
|
|
18
19
|
import {
|
|
@@ -324,3 +325,96 @@ describe("runOpLocally — effect steps", () => {
|
|
|
324
325
|
).rejects.toThrow(/parallel phase/);
|
|
325
326
|
});
|
|
326
327
|
});
|
|
328
|
+
|
|
329
|
+
// ── Step outputs inside an effect (#3948) ───────────────────────────────────
|
|
330
|
+
|
|
331
|
+
describe("runOpLocally — an effect's nested steps read step outputs (#3948)", () => {
|
|
332
|
+
function refOp(): OpConfig {
|
|
333
|
+
return {
|
|
334
|
+
name: "seed-op",
|
|
335
|
+
overview: "",
|
|
336
|
+
phases: [
|
|
337
|
+
phase("Pick", [{ kind: "activity", fn: "pick", id: "pick" }]),
|
|
338
|
+
phase("Seed", [effect(seeded, [step("runSeed", { file: stepOutput("pick", "file") })])]),
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
test("the resolved value reaches the nested step, and the receipt holds its digest", async () => {
|
|
344
|
+
const { store, values } = memStore();
|
|
345
|
+
const received: unknown[] = [];
|
|
346
|
+
let file = "a.sql";
|
|
347
|
+
const activities = activityMap(store, {
|
|
348
|
+
pick: async () => ({ file }),
|
|
349
|
+
runSeed: async (args) => void received.push(args),
|
|
350
|
+
});
|
|
351
|
+
const first = await runOpLocally(refOp(), activities, PROFILES);
|
|
352
|
+
expect(first.status).toBe("ok");
|
|
353
|
+
expect(received).toEqual([{ file: "a.sql" }]);
|
|
354
|
+
const written = values.get("seeded")!;
|
|
355
|
+
expect(written).not.toBe(receiptExpectation(seeded));
|
|
356
|
+
expect(declaredExpectation(written)).toBe(receiptExpectation(seeded));
|
|
357
|
+
|
|
358
|
+
// The same value: the receipt matches and the effect is skipped.
|
|
359
|
+
const second = await runOpLocally(refOp(), activities, PROFILES);
|
|
360
|
+
expect(received).toHaveLength(1);
|
|
361
|
+
expect(second.records.find((r) => r.fn === "receiptRead")?.outcome).toEqual({ name: "EffectApplied", value: true });
|
|
362
|
+
|
|
363
|
+
// A changed value fires the effect again, though the declared inputs did not change.
|
|
364
|
+
file = "b.sql";
|
|
365
|
+
await runOpLocally(refOp(), activities, PROFILES);
|
|
366
|
+
expect(received).toEqual([{ file: "a.sql" }, { file: "b.sql" }]);
|
|
367
|
+
expect(values.get("seeded")).not.toBe(written);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("a receipt written before the effect read outputs re-fires the effect once", async () => {
|
|
371
|
+
const { store, values } = memStore({ seeded: receiptExpectation(seeded) });
|
|
372
|
+
const ran = vi.fn();
|
|
373
|
+
const activities = activityMap(store, { pick: async () => ({ file: "a.sql" }), runSeed: async () => { ran(); } });
|
|
374
|
+
await runOpLocally(refOp(), activities, PROFILES);
|
|
375
|
+
expect(ran).toHaveBeenCalledTimes(1);
|
|
376
|
+
expect(declaredExpectation(values.get("seeded")!)).toBe(receiptExpectation(seeded));
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("an existence receipt takes no outputs: it says the effect ran once", async () => {
|
|
380
|
+
const booted = EffectReceipt("booted", { effect: "bootstrap", flavor: "existence" });
|
|
381
|
+
const config: OpConfig = {
|
|
382
|
+
name: "boot-op",
|
|
383
|
+
overview: "",
|
|
384
|
+
phases: [
|
|
385
|
+
phase("Pick", [{ kind: "activity", fn: "pick", id: "pick" }]),
|
|
386
|
+
phase("Boot", [effect(booted, [step("boot", { file: stepOutput("pick", "file") })])]),
|
|
387
|
+
],
|
|
388
|
+
};
|
|
389
|
+
const { store, values } = memStore();
|
|
390
|
+
const activities = activityMap(store, { pick: async () => ({ file: "a.sql" }), boot: async () => {} });
|
|
391
|
+
await runOpLocally(config, activities, PROFILES);
|
|
392
|
+
expect(values.get("booted")).toBe(EXISTENCE_EXPECTATION);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("an effect in onFailure whose source step did not complete is skipped whole (A6)", async () => {
|
|
396
|
+
const config: OpConfig = {
|
|
397
|
+
name: "seed-op",
|
|
398
|
+
overview: "",
|
|
399
|
+
phases: [phase("Pick", [{ kind: "activity", fn: "pick", id: "pick" }])],
|
|
400
|
+
onFailure: [phase("Undo", [effect(seeded, [step("unseed", { file: stepOutput("pick", "file") })])])],
|
|
401
|
+
};
|
|
402
|
+
const { store, writes } = memStore();
|
|
403
|
+
const ran = vi.fn();
|
|
404
|
+
const activities = activityMap(store, { pick: async () => { throw new Error("boom"); }, unseed: async () => { ran(); } });
|
|
405
|
+
const failure = (await runOpLocally(config, activities, PROFILES).catch((e) => e)) as OpRunFailure;
|
|
406
|
+
expect(failure).toBeInstanceOf(OpRunFailure);
|
|
407
|
+
expect(ran).not.toHaveBeenCalled();
|
|
408
|
+
expect(writes).toEqual([]);
|
|
409
|
+
const skipped = failure.result.records.find((r) => r.fn === "effect:seeded");
|
|
410
|
+
expect(skipped?.status).toBe("skipped");
|
|
411
|
+
expect(skipped?.refusal).toMatch(/step "pick" did not complete/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("receiptStaleness compares the declared part of a value written with outputs", async () => {
|
|
415
|
+
const expected = receiptExpectation(seeded);
|
|
416
|
+
const { store } = memStore({ seeded: withStepOutputs(expected, "sha256:abc") });
|
|
417
|
+
const { receiptStaleness } = receiptActivities(store);
|
|
418
|
+
expect(await receiptStaleness({ receipts: [receiptCheckInput(seeded)] })).toEqual({ stale: false, findings: [] });
|
|
419
|
+
});
|
|
420
|
+
});
|
|
@@ -26,7 +26,7 @@ vi.mock("node:child_process", async () => {
|
|
|
26
26
|
return { ...actual, exec: execStub, execFile: execFileStub };
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
const { clientHosts, emulatorLifecycle, endpointOnHost, hostPortInUse, parsePublishedAddresses, pickReachableEndpoint } =
|
|
29
|
+
const { checkSettings, clientHosts, emulatorLifecycle, endpointOnHost, hostPortInUse, parsePublishedAddresses, pickReachableEndpoint } =
|
|
30
30
|
await import("./emulator-lifecycle");
|
|
31
31
|
|
|
32
32
|
describe("emulatorLifecycle command builders", () => {
|
|
@@ -60,6 +60,29 @@ describe("emulatorLifecycle command builders", () => {
|
|
|
60
60
|
);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("network, settings and loopback go before the image (#3952)", () => {
|
|
64
|
+
const e = emulatorLifecycle({ name: "n", image: "img:1", containerPort: 80, healthPath: "/h", settingsPrefix: "X_" });
|
|
65
|
+
expect(e.runCommand({ network: "loom", settings: { X_MODE: "memory", X_TTL: "30" }, loopback: true })).toBe(
|
|
66
|
+
"docker run -d --rm --name n -p 127.0.0.1:80:80 --network loom -e X_MODE=memory -e X_TTL=30 img:1",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a setting value with spaces or shell syntax is quoted as one word (#3952)", () => {
|
|
71
|
+
const e = emulatorLifecycle({ name: "n", image: "img:1", containerPort: 80, healthPath: "/h", settingsPrefix: "X_" });
|
|
72
|
+
expect(e.runCommand({ settings: { X_A: "a b; rm -rf /", X_B: "it's" } })).toBe(
|
|
73
|
+
"docker run -d --rm --name n -p 80:80 -e 'X_A=a b; rm -rf /' -e 'X_B=it'\\''s' img:1",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("settings outside the prefix, or on a spec without one, are refused (#3952)", () => {
|
|
78
|
+
const e = emulatorLifecycle({ name: "n", image: "img:1", containerPort: 80, healthPath: "/h", settingsPrefix: "X_" });
|
|
79
|
+
expect(() => e.runCommand({ settings: { PATH: "/tmp" } })).toThrow(/setting PATH is not a X_\* variable/);
|
|
80
|
+
expect(() => e.runCommand({ settings: { X_: "1" } })).toThrow(/not a X_\* variable/);
|
|
81
|
+
expect(() => e.runCommand({ settings: { "X_A;B": "1" } })).toThrow(/not a X_\* variable/);
|
|
82
|
+
expect(() => emu.runCommand({ settings: { X_A: "1" } })).toThrow(/takes no settings/);
|
|
83
|
+
expect(() => checkSettings({ name: "n", image: "i", containerPort: 1, settingsPrefix: "X_" }, {})).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
63
86
|
test("exists / rm / health / endpoint", () => {
|
|
64
87
|
expect(emu.existsCommand("n")).toBe("docker ps -q -f name=n");
|
|
65
88
|
expect(emu.rmCommand("n")).toBe("docker rm -f n");
|
|
@@ -41,6 +41,13 @@ export interface EmulatorSpec {
|
|
|
41
41
|
ready?: (healthBody: string) => boolean;
|
|
42
42
|
/** Extra `docker run` args inserted before the image (e.g. a socket mount). */
|
|
43
43
|
runArgs?: readonly string[];
|
|
44
|
+
/**
|
|
45
|
+
* The prefix every key of {@link EmulatorUpArgs.settings} must start with,
|
|
46
|
+
* e.g. `FLOCI_` (#3952). Settings reach the container as `-e KEY=VALUE`, and
|
|
47
|
+
* the prefix keeps them to the emulator's own configuration instead of
|
|
48
|
+
* arbitrary env. An emulator without one takes no settings.
|
|
49
|
+
*/
|
|
50
|
+
settingsPrefix?: string;
|
|
44
51
|
/**
|
|
45
52
|
* Where {@link image} is published, so how far behind the pin is can be
|
|
46
53
|
* answered (#1345).
|
|
@@ -245,6 +252,43 @@ export interface EmulatorUpArgs {
|
|
|
245
252
|
intervalMs?: number;
|
|
246
253
|
/** Additional `docker run` args for this call (after `spec.runArgs`, before the image). */
|
|
247
254
|
extraArgs?: readonly string[];
|
|
255
|
+
/** Docker network the container joins (`--network <name>`) (#3952). */
|
|
256
|
+
network?: string;
|
|
257
|
+
/**
|
|
258
|
+
* Emulator settings passed as `-e KEY=VALUE` (#3952). Every key must start
|
|
259
|
+
* with the spec's {@link EmulatorSpec.settingsPrefix}; see {@link checkSettings}.
|
|
260
|
+
*/
|
|
261
|
+
settings?: Record<string, string>;
|
|
262
|
+
/** Publish the port on 127.0.0.1 only, instead of every host interface (#3952). */
|
|
263
|
+
loopback?: boolean;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Refuse settings an emulator does not take (#3952): a key outside the spec's
|
|
268
|
+
* {@link EmulatorSpec.settingsPrefix}, a key that is not an env-var name, or
|
|
269
|
+
* any setting at all for an emulator that declares no prefix. Throws with the
|
|
270
|
+
* first problem found.
|
|
271
|
+
*/
|
|
272
|
+
export function checkSettings(spec: EmulatorSpec, settings: Record<string, string> | undefined): void {
|
|
273
|
+
const keys = Object.keys(settings ?? {});
|
|
274
|
+
if (keys.length === 0) return;
|
|
275
|
+
const prefix = spec.settingsPrefix;
|
|
276
|
+
if (!prefix) {
|
|
277
|
+
throw new Error(`emulator "${spec.name}" takes no settings; got ${keys.join(", ")}`);
|
|
278
|
+
}
|
|
279
|
+
for (const key of keys) {
|
|
280
|
+
if (!key.startsWith(prefix) || key.length === prefix.length || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
281
|
+
throw new Error(`emulator "${spec.name}": setting ${key} is not a ${prefix}* variable; only ${prefix}<NAME> keys reach the container`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Quote one `docker run` word for the shell `up` runs it through: a setting's
|
|
288
|
+
* value or a network name can hold spaces or shell syntax.
|
|
289
|
+
*/
|
|
290
|
+
function shellWord(word: string): string {
|
|
291
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
248
292
|
}
|
|
249
293
|
|
|
250
294
|
/** The shared lifecycle a per-cloud wrapper adapts. */
|
|
@@ -277,10 +321,14 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
|
|
|
277
321
|
const name = args.name ?? spec.name;
|
|
278
322
|
const port = args.port ?? spec.containerPort;
|
|
279
323
|
const image = args.image ?? spec.image;
|
|
324
|
+
checkSettings(spec, args.settings);
|
|
325
|
+
const bind = args.loopback ? `127.0.0.1:${port}:${spec.containerPort}` : `${port}:${spec.containerPort}`;
|
|
280
326
|
return [
|
|
281
|
-
"docker", "run", "-d", "--rm", "--name", name, "-p",
|
|
327
|
+
"docker", "run", "-d", "--rm", "--name", name, "-p", bind,
|
|
328
|
+
...(args.network ? ["--network", args.network] : []),
|
|
329
|
+
...Object.entries(args.settings ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
|
|
282
330
|
...(spec.runArgs ?? []), ...(args.extraArgs ?? []), image,
|
|
283
|
-
].join(" ");
|
|
331
|
+
].map(shellWord).join(" ");
|
|
284
332
|
};
|
|
285
333
|
const existsCommand = (name: string): string => `docker ps -q -f name=${name}`;
|
|
286
334
|
const rmCommand = (name: string): string => `docker rm -f ${name}`;
|
|
@@ -314,6 +362,8 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
|
|
|
314
362
|
const port = args.port ?? spec.containerPort;
|
|
315
363
|
const timeoutMs = args.timeoutMs ?? 60_000;
|
|
316
364
|
const intervalMs = args.intervalMs ?? 2_000;
|
|
365
|
+
// Before the reuse check, so a bad key is refused whether or not a container runs.
|
|
366
|
+
checkSettings(spec, args.settings);
|
|
317
367
|
|
|
318
368
|
let running = false;
|
|
319
369
|
try {
|
|
@@ -330,6 +380,12 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
|
|
|
330
380
|
// Progress → stderr, so a `--json` consumer (chant emulator, behold) reads
|
|
331
381
|
// clean JSON on stdout. runOp/Op activities capture both streams regardless.
|
|
332
382
|
console.error(`emulator container "${name}" already running — reusing`);
|
|
383
|
+
if (args.image || args.network || args.loopback || Object.keys(args.settings ?? {}).length) {
|
|
384
|
+
console.error(
|
|
385
|
+
`emulator "${name}": the image, network, settings and loopback options apply when a container starts, not to this running one. ` +
|
|
386
|
+
`Run \`chant emulator down\` first to start it with them.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
333
389
|
let published: PublishedAddress[] = [];
|
|
334
390
|
try {
|
|
335
391
|
const { stdout } = await execFileAsync("docker", ["port", name, `${spec.containerPort}/tcp`], { signal });
|
package/src/op/gate.ts
CHANGED
|
@@ -54,6 +54,7 @@ import { gateContextDigest, type GateApprover, type ResolvedGateApproval } from
|
|
|
54
54
|
import { pushLifecycleReport, readBlobBySha, readBlobFromPath, readPathSha, requireLifecycleLedger, writeBlobToPath, RefCASConflictError } from "../lifecycle/git";
|
|
55
55
|
import { parseDuration } from "./duration";
|
|
56
56
|
import { resolveGateRunLocator } from "./gate-run-locator";
|
|
57
|
+
import { refuseGateWithoutRecording } from "../lifecycle/no-record";
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* What appending a pending fact learned about reaching the remote.
|
|
@@ -531,6 +532,8 @@ const EPOCH = new Date(0).toISOString();
|
|
|
531
532
|
* recorded" from "still standing".
|
|
532
533
|
*/
|
|
533
534
|
export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput): Promise<GateCheck> {
|
|
535
|
+
// #3955: a run that records nothing cannot be approved or spend an approval.
|
|
536
|
+
refuseGateWithoutRecording(input.op, input.gate);
|
|
534
537
|
const now = input.now ?? new Date().toISOString();
|
|
535
538
|
const ledger = await port.read(input.op);
|
|
536
539
|
// #2574: a component's ledger holds every environment's facts. This run
|
package/src/op/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export { Op, phase, activity, gate, effect, build, kubectlApply, helmInstall, he
|
|
|
11
11
|
spritesUp, spritesDown } from "./builders";
|
|
12
12
|
export { OpResource } from "./resource";
|
|
13
13
|
export { sleep } from "./activity-runtime";
|
|
14
|
-
export { clientHosts, emulatorLifecycle, emulatorsOf, endpointEnvVars, endpointOnHost, hostPortInUse, parsePublishedAddresses, pickReachableEndpoint } from "./emulator-lifecycle";
|
|
14
|
+
export { checkSettings, clientHosts, emulatorLifecycle, emulatorsOf, endpointEnvVars, endpointOnHost, hostPortInUse, parsePublishedAddresses, pickReachableEndpoint } from "./emulator-lifecycle";
|
|
15
15
|
export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorIdentity, EmulatorUpArgs, EmulatorLifecycle, PublishedAddress } from "./emulator-lifecycle";
|
|
16
16
|
export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";
|
|
17
17
|
export type { FreshnessResult } from "./emulator-freshness";
|
|
@@ -459,6 +459,71 @@ describe("runOpLocally — gate as fact (#2119)", () => {
|
|
|
459
459
|
});
|
|
460
460
|
});
|
|
461
461
|
|
|
462
|
+
describe("runOpLocally — step-output references in onFailure (#3948)", () => {
|
|
463
|
+
const failing: ActivityFn = async () => { throw new Error("boom"); };
|
|
464
|
+
|
|
465
|
+
test("a compensation step receives the value a main-phase step returned", async () => {
|
|
466
|
+
const received: unknown[] = [];
|
|
467
|
+
const activities = new Map<string, ActivityFn>([
|
|
468
|
+
["deploy", async () => ({ previous: "v1" })],
|
|
469
|
+
["migrate", failing],
|
|
470
|
+
["restore", async (args) => { received.push(args); }],
|
|
471
|
+
]);
|
|
472
|
+
const config = op({
|
|
473
|
+
phases: [
|
|
474
|
+
{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy", id: "deploy" }] },
|
|
475
|
+
{ name: "Migrate", steps: [{ kind: "activity", fn: "migrate" }] },
|
|
476
|
+
],
|
|
477
|
+
onFailure: [{ name: "Rollback", steps: [{ kind: "activity", fn: "restore", args: { revision: stepOutput("deploy", "previous") } }] }],
|
|
478
|
+
});
|
|
479
|
+
const err = await runOpLocally(config, activities, PROFILES).catch((e) => e);
|
|
480
|
+
expect(err).toBeInstanceOf(OpRunFailure);
|
|
481
|
+
expect(received).toEqual([{ revision: "v1" }]);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("a compensation step whose source step did not complete is skipped, and the record says why (A6)", async () => {
|
|
485
|
+
const ran: string[] = [];
|
|
486
|
+
const activities = new Map<string, ActivityFn>([
|
|
487
|
+
["deploy", failing],
|
|
488
|
+
["restore", async () => void ran.push("restore")],
|
|
489
|
+
["notify", async () => void ran.push("notify")],
|
|
490
|
+
]);
|
|
491
|
+
const config = op({
|
|
492
|
+
phases: [{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy", id: "deploy" }] }],
|
|
493
|
+
onFailure: [{ name: "Rollback", steps: [
|
|
494
|
+
{ kind: "activity", fn: "restore", args: { revision: stepOutput("deploy", "previous") } },
|
|
495
|
+
{ kind: "activity", fn: "notify" },
|
|
496
|
+
] }],
|
|
497
|
+
});
|
|
498
|
+
const err = await runOpLocally(config, activities, PROFILES).catch((e) => e);
|
|
499
|
+
expect(err).toBeInstanceOf(OpRunFailure);
|
|
500
|
+
// The skip is not a failure of compensation: the next step still runs.
|
|
501
|
+
expect(ran).toEqual(["notify"]);
|
|
502
|
+
const restore = err.result.records.find((r: { fn: string }) => r.fn === "restore");
|
|
503
|
+
expect(restore.status).toBe("skipped");
|
|
504
|
+
expect(restore.refusal).toMatch(/step "deploy" did not complete/);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("the skip applies in a parallel compensation phase too", async () => {
|
|
508
|
+
const ran: string[] = [];
|
|
509
|
+
const activities = new Map<string, ActivityFn>([
|
|
510
|
+
["deploy", failing],
|
|
511
|
+
["restore", async () => void ran.push("restore")],
|
|
512
|
+
["notify", async () => void ran.push("notify")],
|
|
513
|
+
]);
|
|
514
|
+
const config = op({
|
|
515
|
+
phases: [{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy", id: "deploy" }] }],
|
|
516
|
+
onFailure: [{ name: "Rollback", parallel: true, steps: [
|
|
517
|
+
{ kind: "activity", fn: "restore", args: { revision: stepOutput("deploy") } },
|
|
518
|
+
{ kind: "activity", fn: "notify" },
|
|
519
|
+
] }],
|
|
520
|
+
});
|
|
521
|
+
const err = await runOpLocally(config, activities, PROFILES).catch((e) => e);
|
|
522
|
+
expect(ran).toEqual(["notify"]);
|
|
523
|
+
expect(err.result.records.find((r: { fn: string }) => r.fn === "restore").status).toBe("skipped");
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
462
527
|
describe("runOpLocally — step-output references (#1290)", () => {
|
|
463
528
|
test("a later step receives the producer's resolved value, not the placeholder", async () => {
|
|
464
529
|
const received: unknown[] = [];
|
package/src/op/local-executor.ts
CHANGED
|
@@ -23,8 +23,11 @@ import { outcomeAttributesOf } from "./types";
|
|
|
23
23
|
import type { OpConfig, PhaseDefinition, ActivityStep, GateStep, EffectStep, StepDefinition } from "./types";
|
|
24
24
|
import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activity-registry";
|
|
25
25
|
import type { ReceiptReadResult } from "./receipt-store";
|
|
26
|
-
import { isStepOutputRef } from "./step-output-ref";
|
|
26
|
+
import { collectStepOutputRefs, isStepOutputRef } from "./step-output-ref";
|
|
27
|
+
import { canonicalJson } from "../effect-receipt";
|
|
28
|
+
import { contentDigest } from "../content-digest";
|
|
27
29
|
import { parseDuration } from "./duration";
|
|
30
|
+
import { stepLabel, withRunConsole, withStepConsole } from "./step-console";
|
|
28
31
|
import { stepTimeoutProblem } from "./activity-profiles";
|
|
29
32
|
import { describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
|
|
30
33
|
import { gateName } from "./gate-name";
|
|
@@ -277,6 +280,48 @@ function resolveStepOutputRefs(value: unknown, resultsById: ReadonlyMap<string,
|
|
|
277
280
|
return value;
|
|
278
281
|
}
|
|
279
282
|
|
|
283
|
+
/** The activity steps a step runs: itself, or an effect's nested ones. */
|
|
284
|
+
function activitiesOf(step: ActivityStep | EffectStep): ActivityStep[] {
|
|
285
|
+
return isEffect(step) ? step.steps.filter(isActivity) : [step];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The first step a compensation step references that has not completed in
|
|
290
|
+
* this run (#3948 decision A6), or `undefined` when every one did. A failure
|
|
291
|
+
* can stop the run before the step that a rollback reads, and calling the
|
|
292
|
+
* rollback with `undefined` in place of that value would undo the wrong
|
|
293
|
+
* thing, or nothing, while reporting `ok`.
|
|
294
|
+
*/
|
|
295
|
+
function unfinishedSource(step: ActivityStep | EffectStep, resultsById: ReadonlyMap<string, unknown>): string | undefined {
|
|
296
|
+
for (const activity of activitiesOf(step)) {
|
|
297
|
+
const missing = collectStepOutputRefs(activity.args).find((ref) => !resultsById.has(ref.step));
|
|
298
|
+
if (missing) return missing.step;
|
|
299
|
+
}
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The skipped record of a compensation step whose source did not complete. */
|
|
304
|
+
function unfinishedSourceRecord(step: ActivityStep | EffectStep, phaseName: string, source: string): StepRecord {
|
|
305
|
+
const record = isEffect(step) ? skippedRecord(phaseName, `effect:${step.receipt.name}`) : skippedRecord(phaseName, step.fn, step.args);
|
|
306
|
+
return { ...record, refusal: `step "${source}" did not complete in this run, so this compensation step, which reads its output, was skipped` };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The digest of the step outputs an effect's nested steps read (#3948), or
|
|
311
|
+
* `undefined` when they read none. The receipt store folds it into the
|
|
312
|
+
* expectation, so a changed value re-fires the effect instead of the receipt
|
|
313
|
+
* matching on the declared inputs alone. An existence receipt says only that
|
|
314
|
+
* the effect ran once, so it takes none.
|
|
315
|
+
*/
|
|
316
|
+
function effectOutputsDigest(step: EffectStep, resultsById: ReadonlyMap<string, unknown>): string | undefined {
|
|
317
|
+
if (step.receipt.flavor === "existence") return undefined;
|
|
318
|
+
const read = activitiesOf(step).flatMap((a) =>
|
|
319
|
+
collectStepOutputRefs(a.args).map((ref) => ({ step: ref.step, path: ref.path ?? "", value: resolveStepOutputRefs(ref, resultsById) })),
|
|
320
|
+
);
|
|
321
|
+
if (read.length === 0) return undefined;
|
|
322
|
+
return contentDigest(canonicalJson(read));
|
|
323
|
+
}
|
|
324
|
+
|
|
280
325
|
/**
|
|
281
326
|
* Find the first `policyGate` step anywhere in the Op (phases + `onFailure`,
|
|
282
327
|
* including steps nested inside effect steps), if any.
|
|
@@ -409,7 +454,8 @@ async function runStep(
|
|
|
409
454
|
// The in-flight record names the step while it runs.
|
|
410
455
|
const ended = currentLiveRun()?.stepStarted(step.fn, step.id);
|
|
411
456
|
try {
|
|
412
|
-
|
|
457
|
+
// #3956: its output printed as it arrives, and a line while it is quiet.
|
|
458
|
+
return await withStepConsole({ phase: phaseName, label: stepLabel(step.fn, args, step.id) }, attempts);
|
|
413
459
|
} finally {
|
|
414
460
|
ended?.();
|
|
415
461
|
}
|
|
@@ -462,13 +508,14 @@ async function runStep(
|
|
|
462
508
|
/** The step data the executor synthesizes to read a receipt through the store
|
|
463
509
|
* activities (`receiptRead`/`receiptWrite` — provided by the receipt row's
|
|
464
510
|
* lexicon, #1835, or a mock store in tests via `receiptActivities`). */
|
|
465
|
-
function receiptReadStep(step: EffectStep): ActivityStep {
|
|
511
|
+
function receiptReadStep(step: EffectStep, outputs?: string): ActivityStep {
|
|
466
512
|
return {
|
|
467
513
|
kind: "activity",
|
|
468
514
|
fn: "receiptRead",
|
|
469
515
|
args: {
|
|
470
516
|
receipt: step.receipt,
|
|
471
517
|
...(step.expectation !== undefined ? { expectation: step.expectation } : {}),
|
|
518
|
+
...(outputs !== undefined ? { outputs } : {}),
|
|
472
519
|
},
|
|
473
520
|
profile: "fastIdempotent",
|
|
474
521
|
outcomeAttribute: { name: "EffectApplied", from: "applied" },
|
|
@@ -494,6 +541,8 @@ interface GateContext {
|
|
|
494
541
|
passed?: PassedGate[];
|
|
495
542
|
/** The run's hold on its work item's lease (#2748). Absent in compensation phases, which run whatever the lease did. */
|
|
496
543
|
work?: RunWorkLease;
|
|
544
|
+
/** Set in `onFailure` phases (#3948): a step whose referenced step did not complete is skipped. */
|
|
545
|
+
compensating?: boolean;
|
|
497
546
|
/** How a gate that names a decision point asks it (#3170). */
|
|
498
547
|
points?: GatePointAsker;
|
|
499
548
|
/** The environment the run was started for, a point gate's `gate.env` input. */
|
|
@@ -788,7 +837,15 @@ async function runEffectStep(
|
|
|
788
837
|
}> {
|
|
789
838
|
const records: StepRecord[] = [];
|
|
790
839
|
|
|
791
|
-
|
|
840
|
+
let outputs: string | undefined;
|
|
841
|
+
try {
|
|
842
|
+
outputs = effectOutputsDigest(step, resultsById);
|
|
843
|
+
} catch (err) {
|
|
844
|
+
// A resolved value JSON cannot hold (canonicalJson refuses it) would be a wrong expectation.
|
|
845
|
+
pushRecord(records, gates, { phase: phaseName, fn: `effect:${step.receipt.name}`, args: {}, status: "fail", durationMs: 0, error: errMessage(err) });
|
|
846
|
+
return { records, failed: true };
|
|
847
|
+
}
|
|
848
|
+
const read = await runStep(receiptReadStep(step, outputs), phaseName, activities, profiles, resultsById, signal);
|
|
792
849
|
pushRecord(records, gates, read.record);
|
|
793
850
|
if (read.record.status === "fail") return { records, failed: true };
|
|
794
851
|
|
|
@@ -948,7 +1005,10 @@ async function runPhase(
|
|
|
948
1005
|
}
|
|
949
1006
|
}
|
|
950
1007
|
const steps = phase.steps.filter(isActivity);
|
|
951
|
-
const settled = await Promise.all(steps.map((s) =>
|
|
1008
|
+
const settled = await Promise.all(steps.map((s) => {
|
|
1009
|
+
const source = gates.compensating ? unfinishedSource(s, resultsById) : undefined;
|
|
1010
|
+
return source ? { record: unfinishedSourceRecord(s, phase.name, source) } as RanStep : runStep(s, phase.name, activities, profiles, resultsById, signal);
|
|
1011
|
+
}));
|
|
952
1012
|
const ran = settled.map((r) => r.record);
|
|
953
1013
|
for (const r of ran) gates.onRecord?.(r);
|
|
954
1014
|
const records = gateRecords.concat(ran);
|
|
@@ -1009,6 +1069,11 @@ async function runPhase(
|
|
|
1009
1069
|
}
|
|
1010
1070
|
continue;
|
|
1011
1071
|
}
|
|
1072
|
+
const source = gates.compensating ? unfinishedSource(step, resultsById) : undefined;
|
|
1073
|
+
if (source) {
|
|
1074
|
+
pushRecord(records, gates, unfinishedSourceRecord(step, phase.name, source));
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1012
1077
|
if (isEffect(step)) {
|
|
1013
1078
|
const { records: effRecords, failed, pending, pushed, pushWarning, point } = await runEffectStep(
|
|
1014
1079
|
step, phase.name, activities, profiles, resultsById, gates, signal,
|
|
@@ -1109,6 +1174,16 @@ export interface RunOpOptions {
|
|
|
1109
1174
|
work?: { item?: string; holder?: string };
|
|
1110
1175
|
/** Called when a work-lease renewal fails without losing the lease; the next beat retries it. */
|
|
1111
1176
|
onWorkLeaseWarning?: (message: string) => void;
|
|
1177
|
+
/**
|
|
1178
|
+
* Where a running step's output goes, line by line, as it arrives (#3956):
|
|
1179
|
+
* `[<phase>] <step> | <line>` for each line a `shellCmd` command writes, and
|
|
1180
|
+
* `[<phase>] <step>: still running after <n>s` for a step quiet for
|
|
1181
|
+
* `stepHeartbeatMs` (./step-console.ts). The local runtime passes a writer
|
|
1182
|
+
* to stderr. Omitted, nothing is printed while a step runs.
|
|
1183
|
+
*/
|
|
1184
|
+
stepOutput?: (line: string) => void;
|
|
1185
|
+
/** How long a step may be quiet before the still-running line. Default 60s; `0` turns it off. */
|
|
1186
|
+
stepHeartbeatMs?: number;
|
|
1112
1187
|
}
|
|
1113
1188
|
|
|
1114
1189
|
/**
|
|
@@ -1165,9 +1240,14 @@ export async function runOpLocally(
|
|
|
1165
1240
|
})
|
|
1166
1241
|
: undefined;
|
|
1167
1242
|
try {
|
|
1243
|
+
const stepConsole = options.stepOutput
|
|
1244
|
+
? { write: options.stepOutput, ...(options.stepHeartbeatMs !== undefined ? { heartbeatMs: options.stepHeartbeatMs } : {}) }
|
|
1245
|
+
: undefined;
|
|
1168
1246
|
return await withLiveRun(live, () =>
|
|
1169
|
-
|
|
1170
|
-
|
|
1247
|
+
withRunConsole(stepConsole, () =>
|
|
1248
|
+
withOpRunContext(run, () =>
|
|
1249
|
+
runOpInTurn(config, activities, profiles, signal, { ...options, runId, ...(steward ? { steward } : {}) }, run),
|
|
1250
|
+
),
|
|
1171
1251
|
),
|
|
1172
1252
|
);
|
|
1173
1253
|
} finally {
|
|
@@ -1427,7 +1507,7 @@ async function runOpInTurn(
|
|
|
1427
1507
|
// Compensation: run onFailure phases in reverse order (best-effort). Skipped
|
|
1428
1508
|
// on abort (Ctrl-C) — the user asked to stop, so don't start new work.
|
|
1429
1509
|
// It runs whatever the work lease did, so it takes no work boundary.
|
|
1430
|
-
const compensation: GateContext = { ...gates, work: undefined };
|
|
1510
|
+
const compensation: GateContext = { ...gates, work: undefined, compensating: true };
|
|
1431
1511
|
if (!signal?.aborted) {
|
|
1432
1512
|
for (const phase of [...(config.onFailure ?? [])].reverse()) {
|
|
1433
1513
|
try {
|