@intentius/chant 0.124.24 → 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/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +21 -7
- package/dist/cli/mcp/workspace-tools.d.ts +3 -3
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +27 -4
- package/dist/effect-receipt.d.ts +13 -0
- package/dist/effect-receipt.d.ts.map +1 -1
- package/dist/effect-receipt.js +19 -0
- package/dist/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/receipt-plan.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.js +4 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +64 -5
- 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/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/wave-runtime.mjs +213 -213
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/agent-cli.js +3 -0
- package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/dist/workspace/conformance/writer.js +1 -1
- package/dist/workspace/decide.d.ts +51 -9
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/decide.js +125 -14
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +20 -11
- package/dist/workspace/point-answer.schema.json +16 -1
- package/dist/workspace/points-cli.d.ts +5 -3
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points-cli.js +16 -7
- package/dist/workspace/points-write.schema.json +39 -4
- package/dist/workspace/points.schema.json +42 -3
- package/dist/workspace/reason-codes.d.ts +2 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +2 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.js +27 -7
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records-write.js +2 -3
- package/dist/workspace/records.d.ts +10 -4
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/records.js +16 -3
- package/dist/workspace/records.schema.json +2 -1
- package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
- package/dist/workspace/trust/verify-cli.js +2 -1
- package/dist/workspace/trust/verify.d.ts +22 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/trust/verify.js +94 -14
- package/dist/workspace/write-scope.d.ts +3 -1
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -4
- package/package.json +1 -1
- package/src/cli/compiled-cli.e2e.test.ts +45 -1
- package/src/cli/main.test.ts +10 -0
- package/src/cli/main.ts +21 -7
- package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +27 -4
- package/src/effect-receipt.ts +22 -0
- package/src/lexicon-module.ts +9 -2
- package/src/lifecycle/receipt-plan.test.ts +9 -0
- package/src/lifecycle/receipt-plan.ts +4 -1
- package/src/op/effect-step.test.ts +95 -1
- package/src/op/local-executor.test.ts +65 -0
- package/src/op/local-executor.ts +68 -5
- package/src/op/receipt-store.ts +13 -3
- package/src/op/step-output-ref.test.ts +68 -24
- package/src/op/step-output-ref.ts +85 -65
- package/src/workspace/agent-cli.ts +2 -0
- package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/src/workspace/conformance/writer.ts +1 -1
- package/src/workspace/decide.test.ts +87 -3
- package/src/workspace/decide.ts +142 -17
- package/src/workspace/lineage-upgrade.test.ts +21 -0
- package/src/workspace/lineage-upgrade.ts +19 -11
- package/src/workspace/point-answer.schema.json +16 -1
- package/src/workspace/points-cli.ts +16 -8
- package/src/workspace/points-write.schema.json +39 -4
- package/src/workspace/points.schema.json +42 -3
- package/src/workspace/reason-codes.ts +2 -1
- package/src/workspace/record-assets.test.ts +14 -0
- package/src/workspace/records-cli.ts +28 -7
- package/src/workspace/records-write-commands.e2e.test.ts +32 -0
- package/src/workspace/records-write.ts +2 -3
- package/src/workspace/records.schema.json +2 -1
- package/src/workspace/records.ts +17 -4
- package/src/workspace/trust/trust.test.ts +105 -0
- package/src/workspace/trust/verify-cli.ts +2 -1
- package/src/workspace/trust/verify.ts +106 -13
- package/src/workspace/write-scope.test.ts +18 -0
- package/src/workspace/write-scope.ts +16 -5
package/src/effect-receipt.ts
CHANGED
|
@@ -66,6 +66,28 @@ export const EFFECT_RECEIPT_ENTITY_TYPE = "Chant::EffectReceipt";
|
|
|
66
66
|
*/
|
|
67
67
|
export const EXISTENCE_EXPECTATION = "chant.effect-receipt:exists";
|
|
68
68
|
|
|
69
|
+
const OUTPUTS_SEPARATOR = "+outputs=";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A receipt expectation with the digest of the step outputs its effect read
|
|
73
|
+
* appended (#3948). The effect step writes this value, so the next run fires
|
|
74
|
+
* again when one of those outputs changed, though the declared inputs did not.
|
|
75
|
+
*/
|
|
76
|
+
export function withStepOutputs(expectation: string, outputs: string): string {
|
|
77
|
+
return `${expectation}${OUTPUTS_SEPARATOR}${outputs}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The part of a stored receipt value that the declared inputs determine: the
|
|
82
|
+
* value without any step-output digest {@link withStepOutputs} appended. The
|
|
83
|
+
* plan and `receiptStaleness` compare this part, since step outputs exist only
|
|
84
|
+
* while the Op runs.
|
|
85
|
+
*/
|
|
86
|
+
export function declaredExpectation(value: string): string {
|
|
87
|
+
const at = value.indexOf(OUTPUTS_SEPARATOR);
|
|
88
|
+
return at < 0 ? value : value.slice(0, at);
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
/**
|
|
70
92
|
* An effect receipt declaration — a Declarable, so discovery collects it,
|
|
71
93
|
* `chant list` shows it, and (unlike a secret declaration) serialization
|
package/src/lexicon-module.ts
CHANGED
|
@@ -176,12 +176,19 @@ export function resetLexiconModules(): void {
|
|
|
176
176
|
* Import the module a path-declared lexicon names. Returns `undefined` when
|
|
177
177
|
* `name` is package-backed, so a caller falls through to its package import.
|
|
178
178
|
* An import failure is thrown with the declared path in the message.
|
|
179
|
+
*
|
|
180
|
+
* `typeScript: false` imports it with plain node, without registering tsx
|
|
181
|
+
* (#3276): tsx's ESM hooks run on a thread of their own, which costs the
|
|
182
|
+
* compiled CLI about 200 MB, so a caller whose module is JavaScript says so.
|
|
179
183
|
*/
|
|
180
|
-
export async function importLexiconModule(
|
|
184
|
+
export async function importLexiconModule(
|
|
185
|
+
name: string,
|
|
186
|
+
options: { typeScript?: boolean } = {},
|
|
187
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
181
188
|
const path = modulePaths.get(name);
|
|
182
189
|
if (path === undefined) return undefined;
|
|
183
190
|
try {
|
|
184
|
-
ensureTypeScriptLoader();
|
|
191
|
+
if (options.typeScript !== false) ensureTypeScriptLoader();
|
|
185
192
|
return esModuleNamespace((await import(path)) as Record<string, unknown>);
|
|
186
193
|
} catch (err) {
|
|
187
194
|
throw new Error(
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
EXISTENCE_EXPECTATION,
|
|
13
13
|
receiptExpectation,
|
|
14
14
|
resolveReceiptExpectation,
|
|
15
|
+
withStepOutputs,
|
|
15
16
|
collectEffectReceipts,
|
|
16
17
|
type EffectReceiptDeclaration,
|
|
17
18
|
} from "../effect-receipt";
|
|
@@ -70,6 +71,14 @@ describe("planReceipts", () => {
|
|
|
70
71
|
expect(entries[0].effect).toBe("seed");
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
it("a value an effect step wrote with step outputs is a noop when its declared part matches (#3948)", () => {
|
|
75
|
+
const r = EffectReceipt("seeded", { effect: "seed", flavor: "hash", inputs: { rows: 10 } });
|
|
76
|
+
const readings = new Map<string, ReceiptReading>([
|
|
77
|
+
["seeded", { observed: true, present: true, value: withStepOutputs(receiptExpectation(r), "sha256:abc"), lexicon: "aws" }],
|
|
78
|
+
]);
|
|
79
|
+
expect(planReceipts(receiptsOf(["seeded", r]), readings, noRefs)[0].action).toBe("noop");
|
|
80
|
+
});
|
|
81
|
+
|
|
73
82
|
it("matching existence receipt is a clean noop", () => {
|
|
74
83
|
const r = EffectReceipt("bootstrapped", { effect: "bootstrap", flavor: "existence" });
|
|
75
84
|
const readings = new Map<string, ReceiptReading>([
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
import { AttrRef } from "../attrref";
|
|
35
35
|
import {
|
|
36
36
|
EXISTENCE_EXPECTATION,
|
|
37
|
+
declaredExpectation,
|
|
37
38
|
resolveReceiptExpectation,
|
|
38
39
|
type EffectReceiptDeclaration,
|
|
39
40
|
type ReceiptInputResolver,
|
|
@@ -198,7 +199,9 @@ export function planReceipts(
|
|
|
198
199
|
continue;
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
|
|
202
|
+
// A value an effect step wrote with the digest of the step outputs it read
|
|
203
|
+
// (#3948) is compared on its declared part: outputs exist only at run.
|
|
204
|
+
if (typeof reading.value === "string" && declaredExpectation(reading.value) === expectation) {
|
|
202
205
|
entries.push({ ...base, action: "noop", evidence });
|
|
203
206
|
continue;
|
|
204
207
|
}
|
|
@@ -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
|
+
});
|
|
@@ -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,7 +23,9 @@ 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";
|
|
28
30
|
import { stepLabel, withRunConsole, withStepConsole } from "./step-console";
|
|
29
31
|
import { stepTimeoutProblem } from "./activity-profiles";
|
|
@@ -278,6 +280,48 @@ function resolveStepOutputRefs(value: unknown, resultsById: ReadonlyMap<string,
|
|
|
278
280
|
return value;
|
|
279
281
|
}
|
|
280
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
|
+
|
|
281
325
|
/**
|
|
282
326
|
* Find the first `policyGate` step anywhere in the Op (phases + `onFailure`,
|
|
283
327
|
* including steps nested inside effect steps), if any.
|
|
@@ -464,13 +508,14 @@ async function runStep(
|
|
|
464
508
|
/** The step data the executor synthesizes to read a receipt through the store
|
|
465
509
|
* activities (`receiptRead`/`receiptWrite` — provided by the receipt row's
|
|
466
510
|
* lexicon, #1835, or a mock store in tests via `receiptActivities`). */
|
|
467
|
-
function receiptReadStep(step: EffectStep): ActivityStep {
|
|
511
|
+
function receiptReadStep(step: EffectStep, outputs?: string): ActivityStep {
|
|
468
512
|
return {
|
|
469
513
|
kind: "activity",
|
|
470
514
|
fn: "receiptRead",
|
|
471
515
|
args: {
|
|
472
516
|
receipt: step.receipt,
|
|
473
517
|
...(step.expectation !== undefined ? { expectation: step.expectation } : {}),
|
|
518
|
+
...(outputs !== undefined ? { outputs } : {}),
|
|
474
519
|
},
|
|
475
520
|
profile: "fastIdempotent",
|
|
476
521
|
outcomeAttribute: { name: "EffectApplied", from: "applied" },
|
|
@@ -496,6 +541,8 @@ interface GateContext {
|
|
|
496
541
|
passed?: PassedGate[];
|
|
497
542
|
/** The run's hold on its work item's lease (#2748). Absent in compensation phases, which run whatever the lease did. */
|
|
498
543
|
work?: RunWorkLease;
|
|
544
|
+
/** Set in `onFailure` phases (#3948): a step whose referenced step did not complete is skipped. */
|
|
545
|
+
compensating?: boolean;
|
|
499
546
|
/** How a gate that names a decision point asks it (#3170). */
|
|
500
547
|
points?: GatePointAsker;
|
|
501
548
|
/** The environment the run was started for, a point gate's `gate.env` input. */
|
|
@@ -790,7 +837,15 @@ async function runEffectStep(
|
|
|
790
837
|
}> {
|
|
791
838
|
const records: StepRecord[] = [];
|
|
792
839
|
|
|
793
|
-
|
|
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);
|
|
794
849
|
pushRecord(records, gates, read.record);
|
|
795
850
|
if (read.record.status === "fail") return { records, failed: true };
|
|
796
851
|
|
|
@@ -950,7 +1005,10 @@ async function runPhase(
|
|
|
950
1005
|
}
|
|
951
1006
|
}
|
|
952
1007
|
const steps = phase.steps.filter(isActivity);
|
|
953
|
-
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
|
+
}));
|
|
954
1012
|
const ran = settled.map((r) => r.record);
|
|
955
1013
|
for (const r of ran) gates.onRecord?.(r);
|
|
956
1014
|
const records = gateRecords.concat(ran);
|
|
@@ -1011,6 +1069,11 @@ async function runPhase(
|
|
|
1011
1069
|
}
|
|
1012
1070
|
continue;
|
|
1013
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
|
+
}
|
|
1014
1077
|
if (isEffect(step)) {
|
|
1015
1078
|
const { records: effRecords, failed, pending, pushed, pushWarning, point } = await runEffectStep(
|
|
1016
1079
|
step, phase.name, activities, profiles, resultsById, gates, signal,
|
|
@@ -1444,7 +1507,7 @@ async function runOpInTurn(
|
|
|
1444
1507
|
// Compensation: run onFailure phases in reverse order (best-effort). Skipped
|
|
1445
1508
|
// on abort (Ctrl-C) — the user asked to stop, so don't start new work.
|
|
1446
1509
|
// It runs whatever the work lease did, so it takes no work boundary.
|
|
1447
|
-
const compensation: GateContext = { ...gates, work: undefined };
|
|
1510
|
+
const compensation: GateContext = { ...gates, work: undefined, compensating: true };
|
|
1448
1511
|
if (!signal?.aborted) {
|
|
1449
1512
|
for (const phase of [...(config.onFailure ?? [])].reverse()) {
|
|
1450
1513
|
try {
|
package/src/op/receipt-store.ts
CHANGED
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
referenceInputPaths,
|
|
35
35
|
type EffectReceiptDeclaration,
|
|
36
36
|
type EffectReceiptFlavor,
|
|
37
|
+
declaredExpectation,
|
|
38
|
+
withStepOutputs,
|
|
37
39
|
} from "../effect-receipt";
|
|
38
40
|
|
|
39
41
|
/**
|
|
@@ -101,7 +103,14 @@ export function receiptCheckInput(receipt: EffectReceiptDeclaration): ReceiptChe
|
|
|
101
103
|
// ── Activity contracts ────────────────────────────────────────────────────────
|
|
102
104
|
|
|
103
105
|
/** Args of the `receiptRead` activity, as the effect step's codegen emits them. */
|
|
104
|
-
export interface ReceiptReadArgs extends ReceiptCheckInput {
|
|
106
|
+
export interface ReceiptReadArgs extends ReceiptCheckInput {
|
|
107
|
+
/**
|
|
108
|
+
* The digest of the step outputs the effect's nested steps read (#3948),
|
|
109
|
+
* when they read any. Folded into the expectation by {@link withStepOutputs}.
|
|
110
|
+
*/
|
|
111
|
+
outputs?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
105
114
|
|
|
106
115
|
/** Result of the `receiptRead` activity. `current` is `null` (not `undefined`)
|
|
107
116
|
* for an absent receipt so the value survives JSON transport. */
|
|
@@ -183,7 +192,8 @@ export function receiptActivities(store: ReceiptStore, opts?: ReceiptActivityOpt
|
|
|
183
192
|
|
|
184
193
|
return {
|
|
185
194
|
async receiptRead(args: ReceiptReadArgs): Promise<ReceiptReadResult> {
|
|
186
|
-
const
|
|
195
|
+
const declared = await expectationOf(args);
|
|
196
|
+
const expectation = args.outputs !== undefined ? withStepOutputs(declared, args.outputs) : declared;
|
|
187
197
|
const current = await store.read(args.receipt);
|
|
188
198
|
return { current: current ?? null, expectation, applied: current === expectation };
|
|
189
199
|
},
|
|
@@ -201,7 +211,7 @@ export function receiptActivities(store: ReceiptStore, opts?: ReceiptActivityOpt
|
|
|
201
211
|
const current = await store.read(input.receipt);
|
|
202
212
|
if (current === undefined) {
|
|
203
213
|
findings.push({ receipt: input.receipt.name, effect: input.receipt.effect, kind: "absent", expected });
|
|
204
|
-
} else if (current !== expected) {
|
|
214
|
+
} else if (declaredExpectation(current) !== expected) {
|
|
205
215
|
findings.push({ receipt: input.receipt.name, effect: input.receipt.effect, kind: "differs", expected, current });
|
|
206
216
|
}
|
|
207
217
|
}
|
|
@@ -10,7 +10,7 @@ import { z } from "zod";
|
|
|
10
10
|
import { activity, phase, effect } from "./builders";
|
|
11
11
|
import type { OpConfig } from "./types";
|
|
12
12
|
import { activityContract, type ActivityContract } from "./activity-contract";
|
|
13
|
-
import { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs } from "./step-output-ref";
|
|
13
|
+
import { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs, validateStepOutputRefScope } from "./step-output-ref";
|
|
14
14
|
import { EffectReceipt } from "../effect-receipt";
|
|
15
15
|
|
|
16
16
|
const lifecycleDiffContract = activityContract(
|
|
@@ -248,39 +248,83 @@ describe("validateStepOutputRefs() — structural coverage", () => {
|
|
|
248
248
|
expect(issues.some((i) => i.message.includes("declares no return schema"))).toBe(true);
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it("
|
|
251
|
+
it("reports every issue across multiple refs, not just the first", () => {
|
|
252
|
+
const apply1 = activity("applyStacks", { stacks: stepOutput("nope1", "x") });
|
|
253
|
+
const apply2 = activity("applyStacks", { stacks: stepOutput("nope2", "x") });
|
|
254
|
+
const config = opWith({ phases: [phase("P", [apply1, apply2])] });
|
|
255
|
+
const issues = validateStepOutputRefs(config, new Map([["applyStacks", applyStacksContract]]));
|
|
256
|
+
expect(issues.some((i) => i.message.includes("nope1"))).toBe(true);
|
|
257
|
+
expect(issues.some((i) => i.message.includes("nope2"))).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── onFailure and effect steps (#3948) ───────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
describe("validateStepOutputRefs() — onFailure and effect steps (#3948)", () => {
|
|
264
|
+
const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { file: "seed.sql", version: 3 } });
|
|
265
|
+
const contracts = new Map<string, ActivityContract>([
|
|
266
|
+
["lifecycleDiff", lifecycleDiffContract],
|
|
267
|
+
["applyStacks", applyStacksContract],
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
it("an onFailure step may reference any main-phase step", () => {
|
|
252
271
|
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
|
|
272
|
+
const apply = activity("applyStacks", { stacks: ["a"] });
|
|
253
273
|
const rollback = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
|
|
274
|
+
const config = opWith({ phases: [phase("Diff", [diff]), phase("Apply", [apply])], onFailure: [phase("Rollback", [rollback])] });
|
|
275
|
+
expect(validateStepOutputRefs(config, contracts)).toEqual([]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("an onFailure step's reference is still checked against the producer's contract", () => {
|
|
279
|
+
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
|
|
280
|
+
const rollback = activity("applyStacks", { stacks: stepOutput("diff", "nope") });
|
|
254
281
|
const config = opWith({ phases: [phase("P", [diff])], onFailure: [phase("Rollback", [rollback])] });
|
|
255
|
-
const contracts = new Map<string, ActivityContract>([
|
|
256
|
-
["lifecycleDiff", lifecycleDiffContract],
|
|
257
|
-
["applyStacks", applyStacksContract],
|
|
258
|
-
]);
|
|
259
282
|
const issues = validateStepOutputRefs(config, contracts);
|
|
260
|
-
expect(issues
|
|
283
|
+
expect(issues).toHaveLength(1);
|
|
284
|
+
expect(issues[0].phase).toBe("Rollback");
|
|
285
|
+
expect(issues[0].message).toMatch(/path "nope", which does not exist/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("an onFailure step cannot reference another onFailure step", () => {
|
|
289
|
+
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "undoDiff" });
|
|
290
|
+
const rollback = activity("applyStacks", { stacks: stepOutput("undoDiff", "driftedStacks") });
|
|
291
|
+
const config = opWith({ phases: [phase("P", [activity("applyStacks", { stacks: [] })])], onFailure: [phase("Rollback", [diff, rollback])] });
|
|
292
|
+
expect(validateStepOutputRefScope(config)[0].message).toMatch(/unknown step id "undoDiff"/);
|
|
261
293
|
});
|
|
262
294
|
|
|
263
|
-
it("
|
|
264
|
-
const receipt = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { file: "seed.sql", version: 3 } });
|
|
295
|
+
it("a step inside an effect may reference a step that runs before the effect", () => {
|
|
265
296
|
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
|
|
266
297
|
const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
const contracts = new Map<string, ActivityContract>([
|
|
270
|
-
["lifecycleDiff", lifecycleDiffContract],
|
|
271
|
-
["applyStacks", applyStacksContract],
|
|
272
|
-
]);
|
|
273
|
-
const issues = validateStepOutputRefs(config, contracts);
|
|
274
|
-
expect(issues.some((i) => i.message.includes("nested inside an effect step"))).toBe(true);
|
|
298
|
+
const config = opWith({ phases: [phase("P", [diff, effect(seeded, [nested])])] });
|
|
299
|
+
expect(validateStepOutputRefs(config, contracts)).toEqual([]);
|
|
275
300
|
});
|
|
276
301
|
|
|
277
|
-
it("
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
const config = opWith({ phases: [phase("P", [
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
302
|
+
it("a step inside an effect in onFailure may reference a main-phase step", () => {
|
|
303
|
+
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
|
|
304
|
+
const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
|
|
305
|
+
const config = opWith({ phases: [phase("P", [diff])], onFailure: [phase("Undo", [effect(seeded, [nested])])] });
|
|
306
|
+
expect(validateStepOutputRefs(config, contracts)).toEqual([]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("a step inside an effect cannot reference a step after the effect", () => {
|
|
310
|
+
const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
|
|
311
|
+
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
|
|
312
|
+
const config = opWith({ phases: [phase("P", [effect(seeded, [nested]), diff])] });
|
|
313
|
+
expect(validateStepOutputRefScope(config)[0].message).toMatch(/step "diff" does not run before this step/);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("nothing can reference a step nested inside an effect (decision A7)", () => {
|
|
317
|
+
const inner = activity("lifecycleDiff", { env: "prod" }, { id: "inner" });
|
|
318
|
+
const sibling = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
|
|
319
|
+
const later = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
|
|
320
|
+
const rollback = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
|
|
321
|
+
const config = opWith({
|
|
322
|
+
phases: [phase("P", [effect(seeded, [inner, sibling]), later])],
|
|
323
|
+
onFailure: [phase("Rollback", [rollback])],
|
|
324
|
+
});
|
|
325
|
+
const issues = validateStepOutputRefScope(config);
|
|
326
|
+
expect(issues).toHaveLength(3);
|
|
327
|
+
for (const issue of issues) expect(issue.message).toMatch(/"inner", which is nested inside an effect step/);
|
|
284
328
|
});
|
|
285
329
|
});
|
|
286
330
|
|