@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.
Files changed (210) hide show
  1. package/dist/ci-jobs.d.ts +41 -1
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +48 -1
  4. package/dist/cli/commands/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.js +14 -0
  6. package/dist/cli/handlers/approve.d.ts.map +1 -1
  7. package/dist/cli/handlers/approve.js +9 -0
  8. package/dist/cli/handlers/emulator.d.ts +13 -2
  9. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  10. package/dist/cli/handlers/emulator.js +63 -3
  11. package/dist/cli/handlers/promote.d.ts.map +1 -1
  12. package/dist/cli/handlers/promote.js +7 -1
  13. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  14. package/dist/cli/handlers/run-wave.js +5 -4
  15. package/dist/cli/handlers/run.d.ts +4 -3
  16. package/dist/cli/handlers/run.d.ts.map +1 -1
  17. package/dist/cli/handlers/run.js +73 -9
  18. package/dist/cli/main.d.ts.map +1 -1
  19. package/dist/cli/main.js +38 -11
  20. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  21. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  22. package/dist/cli/mcp/workspace-tools.js +27 -4
  23. package/dist/cli/parse-args.d.ts.map +1 -1
  24. package/dist/cli/parse-args.js +31 -0
  25. package/dist/cli/registry.d.ts +12 -0
  26. package/dist/cli/registry.d.ts.map +1 -1
  27. package/dist/components/cli-support.d.ts +45 -6
  28. package/dist/components/cli-support.d.ts.map +1 -1
  29. package/dist/components/cli-support.js +97 -22
  30. package/dist/components/driver.d.ts.map +1 -1
  31. package/dist/components/driver.js +25 -10
  32. package/dist/effect-receipt.d.ts +13 -0
  33. package/dist/effect-receipt.d.ts.map +1 -1
  34. package/dist/effect-receipt.js +19 -0
  35. package/dist/graph-detail.d.ts.map +1 -1
  36. package/dist/graph-detail.js +24 -2
  37. package/dist/lexicon-module.d.ts +7 -1
  38. package/dist/lexicon-module.d.ts.map +1 -1
  39. package/dist/lexicon-module.js +7 -2
  40. package/dist/lifecycle/git.d.ts +11 -0
  41. package/dist/lifecycle/git.d.ts.map +1 -1
  42. package/dist/lifecycle/git.js +62 -6
  43. package/dist/lifecycle/no-record.d.ts +59 -0
  44. package/dist/lifecycle/no-record.d.ts.map +1 -0
  45. package/dist/lifecycle/no-record.js +88 -0
  46. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  47. package/dist/lifecycle/receipt-plan.js +4 -2
  48. package/dist/op/activities/shell.d.ts.map +1 -1
  49. package/dist/op/activities/shell.js +30 -9
  50. package/dist/op/emulator-lifecycle.d.ts +23 -0
  51. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  52. package/dist/op/emulator-lifecycle.js +39 -2
  53. package/dist/op/gate.d.ts.map +1 -1
  54. package/dist/op/gate.js +3 -0
  55. package/dist/op/index.d.ts +1 -1
  56. package/dist/op/index.d.ts.map +1 -1
  57. package/dist/op/index.js +1 -1
  58. package/dist/op/local-executor.d.ts +10 -0
  59. package/dist/op/local-executor.d.ts.map +1 -1
  60. package/dist/op/local-executor.js +71 -7
  61. package/dist/op/op-waves-notify.d.ts +2 -0
  62. package/dist/op/op-waves-notify.d.ts.map +1 -1
  63. package/dist/op/op-waves-notify.js +9 -3
  64. package/dist/op/op-waves-run.d.ts +8 -2
  65. package/dist/op/op-waves-run.d.ts.map +1 -1
  66. package/dist/op/op-waves-run.js +36 -11
  67. package/dist/op/op-waves.d.ts +85 -1
  68. package/dist/op/op-waves.d.ts.map +1 -1
  69. package/dist/op/op-waves.js +153 -1
  70. package/dist/op/receipt-store.d.ts +5 -0
  71. package/dist/op/receipt-store.d.ts.map +1 -1
  72. package/dist/op/receipt-store.js +4 -3
  73. package/dist/op/runtimes/local.d.ts.map +1 -1
  74. package/dist/op/runtimes/local.js +6 -0
  75. package/dist/op/step-console.d.ts +65 -0
  76. package/dist/op/step-console.d.ts.map +1 -0
  77. package/dist/op/step-console.js +140 -0
  78. package/dist/op/step-output-ref.d.ts +26 -18
  79. package/dist/op/step-output-ref.d.ts.map +1 -1
  80. package/dist/op/step-output-ref.js +86 -63
  81. package/dist/point-decision.d.ts.map +1 -1
  82. package/dist/point-decision.js +3 -0
  83. package/dist/wave-runtime.mjs +239 -239
  84. package/dist/workspace/agent-cli.d.ts.map +1 -1
  85. package/dist/workspace/agent-cli.js +3 -0
  86. package/dist/workspace/compose-graph.d.ts +22 -3
  87. package/dist/workspace/compose-graph.d.ts.map +1 -1
  88. package/dist/workspace/compose-graph.js +18 -5
  89. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  90. package/dist/workspace/conformance/writer.js +1 -1
  91. package/dist/workspace/decide.d.ts +51 -9
  92. package/dist/workspace/decide.d.ts.map +1 -1
  93. package/dist/workspace/decide.js +125 -14
  94. package/dist/workspace/graph.schema.json +68 -1
  95. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  96. package/dist/workspace/lineage-upgrade.js +20 -11
  97. package/dist/workspace/nested-graph.d.ts +1 -1
  98. package/dist/workspace/nested-graph.d.ts.map +1 -1
  99. package/dist/workspace/nested-graph.js +5 -1
  100. package/dist/workspace/point-answer.schema.json +16 -1
  101. package/dist/workspace/points-cli.d.ts +5 -3
  102. package/dist/workspace/points-cli.d.ts.map +1 -1
  103. package/dist/workspace/points-cli.js +16 -7
  104. package/dist/workspace/points-write.schema.json +39 -4
  105. package/dist/workspace/points.schema.json +42 -3
  106. package/dist/workspace/reason-codes.d.ts +2 -1
  107. package/dist/workspace/reason-codes.d.ts.map +1 -1
  108. package/dist/workspace/reason-codes.js +2 -1
  109. package/dist/workspace/records-cli.d.ts +12 -2
  110. package/dist/workspace/records-cli.d.ts.map +1 -1
  111. package/dist/workspace/records-cli.js +27 -7
  112. package/dist/workspace/records-write.d.ts.map +1 -1
  113. package/dist/workspace/records-write.js +2 -3
  114. package/dist/workspace/records.d.ts +10 -4
  115. package/dist/workspace/records.d.ts.map +1 -1
  116. package/dist/workspace/records.js +16 -3
  117. package/dist/workspace/records.schema.json +2 -1
  118. package/dist/workspace/runs.d.ts.map +1 -1
  119. package/dist/workspace/runs.js +4 -2
  120. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  121. package/dist/workspace/trust/verify-cli.js +2 -1
  122. package/dist/workspace/trust/verify.d.ts +22 -0
  123. package/dist/workspace/trust/verify.d.ts.map +1 -1
  124. package/dist/workspace/trust/verify.js +94 -14
  125. package/dist/workspace/write-scope.d.ts +3 -1
  126. package/dist/workspace/write-scope.d.ts.map +1 -1
  127. package/dist/workspace/write-scope.js +11 -4
  128. package/package.json +1 -1
  129. package/src/ci-jobs.ts +58 -1
  130. package/src/cli/commands/build.ts +18 -0
  131. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  132. package/src/cli/handlers/approve.ts +9 -0
  133. package/src/cli/handlers/emulator.test.ts +61 -1
  134. package/src/cli/handlers/emulator.ts +65 -4
  135. package/src/cli/handlers/promote.test.ts +7 -0
  136. package/src/cli/handlers/promote.ts +7 -1
  137. package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
  138. package/src/cli/handlers/run-wave.ts +5 -4
  139. package/src/cli/handlers/run.test.ts +126 -0
  140. package/src/cli/handlers/run.ts +69 -9
  141. package/src/cli/main.test.ts +34 -0
  142. package/src/cli/main.ts +38 -11
  143. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  144. package/src/cli/mcp/workspace-tools.ts +27 -4
  145. package/src/cli/parse-args.ts +21 -0
  146. package/src/cli/registry.ts +12 -0
  147. package/src/components/cli-support.test.ts +75 -0
  148. package/src/components/cli-support.ts +130 -25
  149. package/src/components/driver.test.ts +28 -0
  150. package/src/components/driver.ts +23 -10
  151. package/src/components/generate-disabled.test.ts +56 -0
  152. package/src/effect-receipt.ts +22 -0
  153. package/src/graph-ci-edges.test.ts +50 -0
  154. package/src/graph-detail.ts +21 -2
  155. package/src/lexicon-module.ts +9 -2
  156. package/src/lifecycle/git.ts +61 -6
  157. package/src/lifecycle/no-record.test.ts +175 -0
  158. package/src/lifecycle/no-record.ts +108 -0
  159. package/src/lifecycle/receipt-plan.test.ts +9 -0
  160. package/src/lifecycle/receipt-plan.ts +4 -1
  161. package/src/op/activities/shell.ts +28 -6
  162. package/src/op/effect-step.test.ts +95 -1
  163. package/src/op/emulator-lifecycle.test.ts +24 -1
  164. package/src/op/emulator-lifecycle.ts +58 -2
  165. package/src/op/gate.ts +3 -0
  166. package/src/op/index.ts +1 -1
  167. package/src/op/local-executor.test.ts +65 -0
  168. package/src/op/local-executor.ts +88 -8
  169. package/src/op/op-waves-jobs.test.ts +213 -0
  170. package/src/op/op-waves-notify.ts +11 -5
  171. package/src/op/op-waves-run.ts +42 -11
  172. package/src/op/op-waves.ts +202 -2
  173. package/src/op/receipt-store.ts +13 -3
  174. package/src/op/runtimes/local.ts +6 -0
  175. package/src/op/step-console.test.ts +150 -0
  176. package/src/op/step-console.ts +155 -0
  177. package/src/op/step-output-ref.test.ts +68 -24
  178. package/src/op/step-output-ref.ts +85 -65
  179. package/src/point-decision-bundle.test.ts +1 -0
  180. package/src/point-decision.ts +3 -0
  181. package/src/workspace/agent-cli.ts +2 -0
  182. package/src/workspace/compose-graph.test.ts +55 -0
  183. package/src/workspace/compose-graph.ts +28 -6
  184. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  185. package/src/workspace/conformance/writer.ts +1 -1
  186. package/src/workspace/decide.test.ts +87 -3
  187. package/src/workspace/decide.ts +142 -17
  188. package/src/workspace/graph-contract.test.ts +26 -1
  189. package/src/workspace/graph.schema.json +68 -1
  190. package/src/workspace/lineage-upgrade.test.ts +21 -0
  191. package/src/workspace/lineage-upgrade.ts +19 -11
  192. package/src/workspace/nested-graph.test.ts +14 -0
  193. package/src/workspace/nested-graph.ts +7 -2
  194. package/src/workspace/point-answer.schema.json +16 -1
  195. package/src/workspace/points-cli.ts +16 -8
  196. package/src/workspace/points-write.schema.json +39 -4
  197. package/src/workspace/points.schema.json +42 -3
  198. package/src/workspace/reason-codes.ts +2 -1
  199. package/src/workspace/record-assets.test.ts +14 -0
  200. package/src/workspace/records-cli.ts +28 -7
  201. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  202. package/src/workspace/records-write.ts +2 -3
  203. package/src/workspace/records.schema.json +2 -1
  204. package/src/workspace/records.ts +17 -4
  205. package/src/workspace/runs.ts +4 -2
  206. package/src/workspace/trust/trust.test.ts +105 -0
  207. package/src/workspace/trust/verify-cli.ts +2 -1
  208. package/src/workspace/trust/verify.ts +106 -13
  209. package/src/workspace/write-scope.test.ts +18 -0
  210. 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 { stdout, stderr } = await execAsync(args.cmd, {
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 (stderr) console.error(stderr);
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 (err) {
129
- const failure = err as ExecFailure;
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
- if (stderrText) console.error(stderrText);
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
- if (stderrText) console.error(stderrText);
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", `${port}:${spec.containerPort}`,
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[] = [];
@@ -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
- return await attempts();
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
- const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, resultsById, signal);
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) => runStep(s, phase.name, activities, profiles, resultsById, signal)));
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
- withOpRunContext(run, () =>
1170
- runOpInTurn(config, activities, profiles, signal, { ...options, runId, ...(steward ? { steward } : {}) }, run),
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 {