@intentius/chant 0.105.1 → 0.106.3

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 (111) hide show
  1. package/dist/change-set.d.ts +30 -8
  2. package/dist/change-set.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts +11 -0
  4. package/dist/cli/commands/build.d.ts.map +1 -1
  5. package/dist/cli/commands/lint.d.ts.map +1 -1
  6. package/dist/cli/handlers/build.d.ts.map +1 -1
  7. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  8. package/dist/cli/handlers/operator.d.ts.map +1 -1
  9. package/dist/cli/handlers/pr.d.ts +16 -2
  10. package/dist/cli/handlers/pr.d.ts.map +1 -1
  11. package/dist/cli/handlers/promote.d.ts.map +1 -1
  12. package/dist/cli/handlers/run.d.ts.map +1 -1
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  15. package/dist/cli/registry.d.ts +7 -0
  16. package/dist/cli/registry.d.ts.map +1 -1
  17. package/dist/components/changed-files.d.ts +8 -2
  18. package/dist/components/changed-files.d.ts.map +1 -1
  19. package/dist/components/fan-out-record.d.ts +24 -0
  20. package/dist/components/fan-out-record.d.ts.map +1 -1
  21. package/dist/components/pr-pipeline.d.ts +67 -4
  22. package/dist/components/pr-pipeline.d.ts.map +1 -1
  23. package/dist/components/pr-run.d.ts +94 -3
  24. package/dist/components/pr-run.d.ts.map +1 -1
  25. package/dist/gated-waves.d.ts +4 -1
  26. package/dist/gated-waves.d.ts.map +1 -1
  27. package/dist/lexicon.d.ts +13 -2
  28. package/dist/lexicon.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  30. package/dist/op/gate-name.d.ts +7 -0
  31. package/dist/op/gate-name.d.ts.map +1 -1
  32. package/dist/op/gate-summary.d.ts +2 -0
  33. package/dist/op/gate-summary.d.ts.map +1 -1
  34. package/dist/op/gate.d.ts +10 -2
  35. package/dist/op/gate.d.ts.map +1 -1
  36. package/dist/op/index.d.ts +2 -2
  37. package/dist/op/index.d.ts.map +1 -1
  38. package/dist/op/local-executor.d.ts.map +1 -1
  39. package/dist/op/local-output.d.ts +2 -2
  40. package/dist/op/local-output.d.ts.map +1 -1
  41. package/dist/op/runtime.d.ts +29 -4
  42. package/dist/op/runtime.d.ts.map +1 -1
  43. package/dist/op/runtimes/local.d.ts.map +1 -1
  44. package/dist/pr-loop.d.ts +32 -6
  45. package/dist/pr-loop.d.ts.map +1 -1
  46. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  47. package/dist/workspace/member-pipeline.d.ts +15 -1
  48. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/__fixtures__/change-set/lifecycle.change-set.golden.json +1 -1
  51. package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
  52. package/src/__fixtures__/change-set/warden.plan.json +47 -40
  53. package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
  54. package/src/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +1 -1
  55. package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +2 -2
  56. package/src/__fixtures__/pr-loop/pr-plan.golden.json +2 -2
  57. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +2 -2
  58. package/src/build.test.ts +1 -1
  59. package/src/build.ts +1 -1
  60. package/src/change-set.test.ts +42 -12
  61. package/src/change-set.ts +50 -11
  62. package/src/cli/commands/build.ts +33 -14
  63. package/src/cli/commands/lint.ts +34 -1
  64. package/src/cli/handlers/build-lexicon-output.test.ts +102 -0
  65. package/src/cli/handlers/build.ts +68 -0
  66. package/src/cli/handlers/fan-out.test.ts +12 -1
  67. package/src/cli/handlers/fan-out.ts +7 -3
  68. package/src/cli/handlers/operator.ts +1 -2
  69. package/src/cli/handlers/pr.ts +223 -35
  70. package/src/cli/handlers/promote.test.ts +15 -1
  71. package/src/cli/handlers/promote.ts +5 -3
  72. package/src/cli/handlers/run.test.ts +215 -1
  73. package/src/cli/handlers/run.ts +36 -15
  74. package/src/cli/main.ts +13 -1
  75. package/src/cli/mcp/never-over-mcp.test.ts +120 -0
  76. package/src/cli/mcp/op-approve-origin.test.ts +1 -1
  77. package/src/cli/mcp/op-tools.ts +14 -2
  78. package/src/cli/mcp/server.test.ts +2 -1
  79. package/src/cli/registry.ts +7 -0
  80. package/src/components/changed-files.test.ts +58 -0
  81. package/src/components/changed-files.ts +19 -4
  82. package/src/components/fan-out-record.test.ts +47 -2
  83. package/src/components/fan-out-record.ts +53 -0
  84. package/src/components/pr-pipeline.ts +96 -6
  85. package/src/components/pr-run.test.ts +311 -4
  86. package/src/components/pr-run.ts +198 -5
  87. package/src/gated-waves.test.ts +11 -0
  88. package/src/gated-waves.ts +7 -2
  89. package/src/lexicon.ts +13 -1
  90. package/src/lifecycle/gate-ledger.ts +4 -3
  91. package/src/op/gate-name.ts +17 -0
  92. package/src/op/gate-summary.test.ts +12 -0
  93. package/src/op/gate-summary.ts +3 -1
  94. package/src/op/gate.test.ts +15 -1
  95. package/src/op/gate.ts +21 -4
  96. package/src/op/index.ts +2 -2
  97. package/src/op/local-executor.test.ts +35 -0
  98. package/src/op/local-executor.ts +2 -2
  99. package/src/op/local-output.test.ts +20 -0
  100. package/src/op/local-output.ts +4 -4
  101. package/src/op/runtime.ts +31 -4
  102. package/src/op/runtimes/local.test.ts +15 -0
  103. package/src/op/runtimes/local.ts +3 -1
  104. package/src/pr-loop.test.ts +41 -1
  105. package/src/pr-loop.ts +41 -10
  106. package/src/workspace/change-set.schema.json +1 -1
  107. package/src/workspace/lineage-upgrade-cli.ts +4 -3
  108. package/src/workspace/lineage-upgrade.test.ts +40 -0
  109. package/src/workspace/member-pipeline.test.ts +28 -0
  110. package/src/workspace/member-pipeline.ts +21 -3
  111. package/src/workspace/pr-report.schema.json +14 -1
@@ -12,8 +12,21 @@ import { describe, expect, test } from "vitest";
12
12
  import { CapabilityRegistry, type DeployContext } from "./capability";
13
13
  import { memoryGateLedgerPort } from "../op/gate";
14
14
  import { planFanOut } from "./fan-out";
15
- import { applyPrSet, approvalOf, approversWithoutReview, decidePrGate, planPrSet, readOnlyLedger } from "./pr-run";
16
- import { changeSetDigest } from "../change-set";
15
+ import {
16
+ applyPrSet,
17
+ approvalOf,
18
+ approversWithoutReview,
19
+ decidePrGate,
20
+ planCoveredBy,
21
+ planPrSet,
22
+ prReport,
23
+ readOnlyLedger,
24
+ resumePrSet,
25
+ type PrPlanSet,
26
+ } from "./pr-run";
27
+ import { changeSetDigest, changeSetDocumentDigest, composeChangeSet, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
28
+ import type { PrApplyRecord } from "./fan-out-record";
29
+ import type { FanOutPlan } from "./fan-out";
17
30
  import { computePlanDigest } from "../lifecycle/plan-digest";
18
31
  import type { DriverComponent } from "./driver";
19
32
 
@@ -66,7 +79,17 @@ function world() {
66
79
  changeSet: {
67
80
  member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] },
68
81
  entries: changes
69
- ? [{ member: input.root, lexicon: "terraform", planner: "tofu", address: "terraform_data.x", type: "terraform_data", action: "update", attributes: [] }]
82
+ ? [
83
+ {
84
+ member: input.root,
85
+ lexicon: "terraform",
86
+ planner: "tofu",
87
+ address: "terraform_data.x",
88
+ type: "terraform_data",
89
+ action: "update",
90
+ attributes: [{ path: "version", before: "v1", after: w.versions[input.root] }],
91
+ },
92
+ ]
70
93
  : [],
71
94
  },
72
95
  };
@@ -117,7 +140,8 @@ describe("planPrSet", () => {
117
140
  ["a", "planned", 1],
118
141
  ["app", "planned", 0],
119
142
  ]);
120
- expect(set.doc.digest).toBe(changeSetDigest(set.members.map((m) => ({ member: m.member, planDigest: m.planDigest }))));
143
+ expect(set.doc.digest).toBe(changeSetDocumentDigest(set.doc));
144
+ expect(set.doc.digest).not.toBe(changeSetDigest(set.members.map((m) => ({ member: m.member, planDigest: m.planDigest }))));
121
145
  expect(set.failed).toBe(false);
122
146
  });
123
147
 
@@ -203,3 +227,286 @@ describe("applyPrSet", () => {
203
227
  expect(members.find((m) => m.member === "a")?.error).toBe("a failed");
204
228
  });
205
229
  });
230
+
231
+ /** The record a first attempt leaves (`pr-apply --resume`), from the set it applied. */
232
+ function recordOf(set: PrPlanSet, approvedBy = "github:alice"): PrApplyRecord {
233
+ return {
234
+ op: "pr-12",
235
+ gate: "pr-apply",
236
+ head: "2".repeat(40),
237
+ digest: set.doc.digest,
238
+ approvedBy: [approvedBy],
239
+ members: set.members,
240
+ changeSet: set.doc,
241
+ planOutputs: set.outputs,
242
+ };
243
+ }
244
+
245
+ /** Plan and apply once, with `failing` failing to apply. */
246
+ async function firstAttempt(w: ReturnType<typeof world>, plan: FanOutPlan, failing: string[]) {
247
+ for (const f of failing) w.failing.add(f);
248
+ const set = await planPrSet({ components: ESTATE, plan, registry: w.registry, env: "prod" });
249
+ const first = await applyPrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", set });
250
+ for (const f of failing) w.failing.delete(f);
251
+ return { set, first, record: recordOf(set) };
252
+ }
253
+
254
+ describe("resuming an apply that failed partway (#3464)", () => {
255
+ test("plans and applies only what did not apply, and the set keeps the approved digest", async () => {
256
+ const w = world();
257
+ const plan = planFanOut({ components: ESTATE, changed: ["net", "dns"] });
258
+ const { set, first, record } = await firstAttempt(w, plan, ["a"]);
259
+ expect(first.status).toBe("failed");
260
+ expect(first.run.completed).toEqual(["b", "dns", "net"]);
261
+
262
+ w.planned.length = 0;
263
+ w.applied.length = 0;
264
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
265
+ if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
266
+
267
+ expect(resumed.plan.order).toEqual(["a", "app"]);
268
+ // Planned against the outputs the approved plans read, so each member plans as it did.
269
+ expect(w.planned).toEqual([
270
+ { root: "a", vars: { cidr: "10.0.0.0/16" } },
271
+ { root: "app", vars: { subnet: "a-v1" } },
272
+ ]);
273
+ expect(resumed.set.doc.digest).toBe(set.doc.digest);
274
+ expect(resumed.fresh.members.map((m) => m.planDigest)).toEqual(
275
+ set.members.filter((m) => m.member === "a" || m.member === "app").map((m) => m.planDigest),
276
+ );
277
+
278
+ // The gate is decided against the digest that was approved, and it still stands.
279
+ const ledger = memoryGateLedgerPort({ resolutions: [approval(set.doc.digest)] });
280
+ const check = await decidePrGate(ledger, { op: "pr-12", gate: "pr-apply", digest: resumed.set.doc.digest, now: NOW });
281
+ expect(check.satisfied).toBe(true);
282
+ expect(ledger.appended).toEqual([]);
283
+
284
+ const second = await applyPrSet({
285
+ components: ESTATE,
286
+ plan: resumed.plan,
287
+ registry: w.registry,
288
+ env: "prod",
289
+ set: resumed.set,
290
+ resumed: { completed: first.run.completed, outputs: {} },
291
+ });
292
+ expect(second.status).toBe("applied");
293
+ expect(w.applied).toEqual(["a", "app"]);
294
+ expect(w.fromPlan.a).toBe(true);
295
+ expect(Object.fromEntries(second.members.map((m) => [m.member, m.status]))).toEqual({
296
+ a: "applied",
297
+ app: "applied",
298
+ b: "applied",
299
+ dns: "applied",
300
+ net: "applied",
301
+ });
302
+ });
303
+
304
+ test("the resumed report carries the approved digest and names what the earlier attempt applied", async () => {
305
+ const w = world();
306
+ const plan = planFanOut({ components: ESTATE, changed: ["net", "dns"] });
307
+ const { set, first, record } = await firstAttempt(w, plan, ["a"]);
308
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
309
+ if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
310
+ const second = await applyPrSet({
311
+ components: ESTATE,
312
+ plan: resumed.plan,
313
+ registry: w.registry,
314
+ env: "prod",
315
+ set: resumed.set,
316
+ resumed: { completed: first.run.completed, outputs: {} },
317
+ });
318
+ const report = prReport({
319
+ stage: "apply",
320
+ pr: 12,
321
+ env: "prod",
322
+ base: "1".repeat(40),
323
+ head: "2".repeat(40),
324
+ op: "pr-12",
325
+ gate: "pr-apply",
326
+ plan,
327
+ signal: { changed: ["net", "dns"], unclaimed: [] } as never,
328
+ set: resumed.set,
329
+ approval: { status: "approved", approvedBy: ["github:alice"] },
330
+ status: second.status,
331
+ members: second.members,
332
+ resumed: first.run.completed,
333
+ });
334
+ expect(report.digest).toBe(set.doc.digest);
335
+ expect(report.changeSet).toBe(set.doc);
336
+ expect(report.resumed).toEqual({ applied: ["b", "dns", "net"] });
337
+ });
338
+
339
+ test("a dependent whose dependency applied in the earlier attempt applies its approved plan and says its inputs moved", async () => {
340
+ const w = world();
341
+ w.versions.a = "v2";
342
+ const plan = planFanOut({ components: ESTATE, changed: ["a"] });
343
+ const { first, record } = await firstAttempt(w, plan, ["app"]);
344
+ expect(first.run.completed).toEqual(["a"]);
345
+ const outputs = { a: first.run.componentOutputs.a };
346
+ expect(outputs.a).toEqual({ id: "a-v2" });
347
+
348
+ w.planned.length = 0;
349
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: ["a"] });
350
+ if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
351
+ // app is planned against the value it was approved with, not the one a wrote since.
352
+ expect(w.planned).toEqual([{ root: "app", vars: { subnet: "a-v1" } }]);
353
+
354
+ const second = await applyPrSet({
355
+ components: ESTATE,
356
+ plan: resumed.plan,
357
+ registry: w.registry,
358
+ env: "prod",
359
+ set: resumed.set,
360
+ resumed: { completed: ["a"], outputs },
361
+ });
362
+ expect(second.members.map((m) => [m.member, m.status, m.inputsMoved])).toEqual([
363
+ ["a", "applied", undefined],
364
+ ["app", "applied", ["a"]],
365
+ ]);
366
+ });
367
+
368
+ test("a member left over whose plan moved is not covered, and says why", async () => {
369
+ const w = world();
370
+ w.versions.a = "v2";
371
+ const plan = planFanOut({ components: ESTATE, changed: ["net", "a"] });
372
+ const { first, record } = await firstAttempt(w, plan, ["a"]);
373
+ expect(first.run.completed).toEqual(["b", "net"]);
374
+
375
+ w.versions.a = "v3";
376
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
377
+ expect(resumed.ok).toBe(false);
378
+ if (resumed.ok) return;
379
+ expect(resumed.reasons).toEqual(["a now writes terraform_data.x.version to a different value than the approved plan"]);
380
+ });
381
+
382
+ test("a member left over that fails to plan is not covered", async () => {
383
+ const w = world();
384
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
385
+ const { first, record } = await firstAttempt(w, plan, ["a"]);
386
+ w.planFailing.add("a");
387
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
388
+ expect(resumed.ok).toBe(false);
389
+ if (resumed.ok) return;
390
+ expect(resumed.reasons[0]).toMatch(/^a failed to plan/);
391
+ });
392
+
393
+ test("a record whose change set is not the one its digest names does not resume (#3543)", async () => {
394
+ const w = world();
395
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
396
+ const { set, first, record } = await firstAttempt(w, plan, ["a"]);
397
+ const other = { ...set.doc, digest: "jcs1-sha256:" + "0".repeat(64) };
398
+ for (const forged of [
399
+ { ...record, changeSet: other },
400
+ { ...record, digest: other.digest, changeSet: other },
401
+ { ...record, changeSet: { ...set.doc, members: set.doc.members.map((m) => (m.member === "a" ? { ...m, planDigest: "jcs1-sha256:" + "1".repeat(64) } : m)) } },
402
+ // The digest binds the entries (#3555): an entry or a hole planted in the record does not resume.
403
+ {
404
+ ...record,
405
+ changeSet: {
406
+ ...set.doc,
407
+ entries: [...set.doc.entries, { member: "a", lexicon: "terraform", planner: "tofu" as const, address: "aws_vpc.planted", type: "aws_vpc", action: "create" as const, attributes: [] }],
408
+ },
409
+ },
410
+ { ...record, changeSet: { ...set.doc, members: set.doc.members.map((m) => (m.member === "a" ? { ...m, holes: [{ address: "data.planted", reason: "unobserved" }] } : m)) } },
411
+ ]) {
412
+ w.planned.length = 0;
413
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record: forged, completed: first.run.completed });
414
+ expect(resumed.ok).toBe(false);
415
+ if (resumed.ok) return;
416
+ expect(resumed.reasons[0]).toMatch(/^the record's change set is not the one its digest/);
417
+ expect(w.planned).toEqual([]);
418
+ }
419
+ });
420
+
421
+ test("with every component applied, nothing is left to plan or apply", async () => {
422
+ const w = world();
423
+ const plan = planFanOut({ components: ESTATE, changed: ["dns"] });
424
+ const { first, record } = await firstAttempt(w, plan, []);
425
+ expect(first.status).toBe("applied");
426
+ w.planned.length = 0;
427
+ const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
428
+ if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
429
+ expect(resumed.plan.order).toEqual([]);
430
+ expect(w.planned).toEqual([]);
431
+ });
432
+ });
433
+
434
+ describe("planCoveredBy", () => {
435
+ const entry = (address: string, action: ChangeSetEntry["action"], attributes: ChangeSetEntry["attributes"] = [], extra: Partial<ChangeSetEntry> = {}): ChangeSetEntry => ({
436
+ member: "net",
437
+ lexicon: "terraform",
438
+ planner: "tofu",
439
+ address,
440
+ type: address.split(".")[0],
441
+ action,
442
+ attributes,
443
+ ...extra,
444
+ });
445
+ const doc = (digest: string, entries: ChangeSetEntry[], extra: Partial<ChangeSetPart["member"]> = {}) =>
446
+ composeChangeSet([
447
+ {
448
+ member: { member: "net", lexicon: "terraform", planner: "tofu", status: "planned", planDigest: `jcs1-sha256:${digest.repeat(64)}`, holes: [], ...extra },
449
+ entries,
450
+ },
451
+ ]);
452
+
453
+ const approved = doc("1", [
454
+ entry("aws_vpc.main", "update", [{ path: "tags", before: { a: "1" }, after: { a: "2" } }]),
455
+ entry("aws_subnet.a", "create", [{ path: "vpc_id", unknown: true }, { path: "cidr_block", after: "10.0.1.0/24" }]),
456
+ entry("aws_instance.web", "replace", [{ path: "ami", before: "ami-1", after: "ami-2", forcesReplacement: true }]),
457
+ entry("aws_db_instance.db", "update", [{ path: "password", sensitive: true }]),
458
+ ]);
459
+
460
+ test("the same plan digest is covered", () => {
461
+ expect(planCoveredBy(approved, doc("1", []), "net")).toBeUndefined();
462
+ });
463
+
464
+ test("what is left of the approved changes is covered", () => {
465
+ // The VPC applied, the subnet's vpc_id is known now, and the instance's
466
+ // old object was destroyed before the create failed.
467
+ const left = doc("2", [
468
+ entry("aws_vpc.main", "no-op"),
469
+ entry("aws_subnet.a", "create", [{ path: "vpc_id", after: "vpc-123" }, { path: "cidr_block", after: "10.0.1.0/24" }]),
470
+ entry("aws_instance.web", "create", [{ path: "ami", after: "ami-2" }, { path: "instance_type", after: "t3.micro" }]),
471
+ entry("aws_db_instance.db", "update", [{ path: "password", sensitive: true }]),
472
+ ]);
473
+ expect(planCoveredBy(approved, left, "net")).toBeUndefined();
474
+ });
475
+
476
+ test("a create-before-destroy replace left with its deposed object to delete is covered", () => {
477
+ const left = doc("2", [entry("aws_instance.web", "delete", [], { deposed: "00abc" })]);
478
+ expect(planCoveredBy(approved, left, "net")).toBeUndefined();
479
+ });
480
+
481
+ test("a change the approved plan did not make is not covered", () => {
482
+ expect(planCoveredBy(approved, doc("2", [entry("aws_s3_bucket.logs", "create")]), "net")).toBe(
483
+ "net now plans to create aws_s3_bucket.logs, which the approved plan did not",
484
+ );
485
+ expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "delete")]), "net")).toBe(
486
+ "net now plans to delete aws_vpc.main, where the approved plan would update it",
487
+ );
488
+ expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "update", [{ path: "tags", before: { a: "1" }, after: { a: "3" } }])]), "net")).toBe(
489
+ "net now writes aws_vpc.main.tags to a different value than the approved plan",
490
+ );
491
+ expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "update", [{ path: "cidr_block", before: "a", after: "b" }])]), "net")).toBe(
492
+ "net now writes aws_vpc.main.cidr_block, which the approved plan did not",
493
+ );
494
+ });
495
+
496
+ test("a sensitive value is covered only where the approved plan wrote it as sensitive", () => {
497
+ expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "update", [{ path: "tags", sensitive: true }])]), "net")).toMatch(/as sensitive/);
498
+ expect(planCoveredBy(approved, doc("2", [entry("aws_db_instance.db", "update", [{ path: "password", after: "x" }])]), "net")).toMatch(/wrote as sensitive/);
499
+ });
500
+
501
+ test("a hole the approved plan did not have is not covered", () => {
502
+ const left = doc("2", [], { holes: [{ address: "data.aws_ami.latest", reason: "unknown" }] });
503
+ expect(planCoveredBy(approved, left, "net")).toMatch(/cannot read data.aws_ami.latest/);
504
+ });
505
+
506
+ test("a member the approved set did not have is not covered", () => {
507
+ const other = composeChangeSet([
508
+ { member: { member: "edge", lexicon: "terraform", planner: "tofu", status: "planned", planDigest: `jcs1-sha256:${"3".repeat(64)}`, holes: [] }, entries: [] },
509
+ ]);
510
+ expect(planCoveredBy(approved, other, "edge")).toBe("edge was not in the approved plan");
511
+ });
512
+ });
@@ -18,9 +18,22 @@
18
18
  * it against the new value. A change that must carry a moved output through
19
19
  * its dependents in one rollout is what gated waves are for (#3049): each
20
20
  * wave is planned after the one before it applied.
21
+ *
22
+ * ## Finishing an apply that failed partway
23
+ *
24
+ * {@link resumePrSet} (#3464) is fan-out's resume (#2417, #3049) for a pull
25
+ * request. The attempt record (`./fan-out-record.ts`) keeps the change set
26
+ * the gate approved and the components that applied. A re-run on the same
27
+ * commit narrows the derivation to what is left (`remainingFanOut`), plans
28
+ * only that against the outputs the approved plans read, and checks each
29
+ * member it plans against the approved set. When every one is covered, the
30
+ * gate is decided against the approved digest, which still stands, and only
31
+ * the rest applies. Planning everything again would give the applied members
32
+ * empty plans and the set a new digest, and so ask for a second approval of
33
+ * work already approved.
21
34
  */
22
35
 
23
- import { composeChangeSet, type ChangeSetDocument, type ChangeSetPart } from "../change-set";
36
+ import { composeChangeSet, verifyChangeSetDigest, type ChangeSetDocument, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
24
37
  import { evaluateGate, type GateCheck, type GateLedgerPort } from "../op/gate";
25
38
  import {
26
39
  memberCounts,
@@ -32,10 +45,11 @@ import {
32
45
  type PrReport,
33
46
  type PrStatus,
34
47
  } from "../pr-loop";
35
- import { deployContext, type DriverComponent } from "./driver";
48
+ import { deployContext, type DriverComponent, type DriverComponentResult } from "./driver";
36
49
  import { deployUnits } from "./deploy-units";
37
50
  import { runFanOut, type FanOutRunResult } from "./fan-out-run";
38
- import type { ComponentChangeSignal, FanOutPlan } from "./fan-out";
51
+ import { remainingFanOut, type ComponentChangeSignal, type FanOutPlan } from "./fan-out";
52
+ import type { PrApplyRecord } from "./fan-out-record";
39
53
  import type { CapabilityRegistry } from "./capability";
40
54
  import type { RunProgressEvent } from "./run-progress";
41
55
  import { planWaveComponent, readComponentOutputs } from "./wave-plan";
@@ -189,9 +203,17 @@ export interface PrApplyOptions {
189
203
  registry: CapabilityRegistry;
190
204
  env: string;
191
205
  vars?: Record<string, unknown>;
192
- /** The set {@link planPrSet} planned and the gate approved. */
206
+ /** The set {@link planPrSet} planned and the gate approved, or {@link resumePrSet}'s set. */
193
207
  set: PrPlanSet;
194
208
  onProgress?: (event: RunProgressEvent) => void;
209
+ /**
210
+ * What an earlier attempt at this approved set applied (#3464): its
211
+ * components, which are reported applied and not run again, and the
212
+ * outputs they left.
213
+ */
214
+ resumed?: { completed: string[]; outputs: Record<string, Record<string, unknown>> };
215
+ /** Called as each component settles, so an attempt record survives a kill (`runFanOut`). */
216
+ onComponentSettled?: (result: DriverComponentResult, outputs: Record<string, unknown> | undefined) => void;
195
217
  }
196
218
 
197
219
  const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
@@ -208,8 +230,19 @@ export async function applyPrSet(options: PrApplyOptions): Promise<{ members: Pr
208
230
  plans: options.set.plans,
209
231
  componentOutputs: { ...before },
210
232
  ...(options.onProgress ? { onProgress: options.onProgress } : {}),
233
+ ...(options.onComponentSettled ? { onComponentSettled: options.onComponentSettled } : {}),
211
234
  });
212
235
  const completed = new Set(run.completed);
236
+ const priorCompleted = new Set(options.resumed?.completed ?? []);
237
+ /**
238
+ * Whether a dependency's outputs moved from what the plans read: by this
239
+ * run's apply, or by the earlier attempt's when the record kept them.
240
+ */
241
+ const movedBy = (dep: string): boolean => {
242
+ if (completed.has(dep)) return !sameJson(before[dep], run.componentOutputs[dep]);
243
+ const earlier = options.resumed?.outputs;
244
+ return priorCompleted.has(dep) && earlier !== undefined && dep in earlier && !sameJson(before[dep], earlier[dep]);
245
+ };
213
246
  const blocked = new Set(run.blocked.map((b) => b.component));
214
247
  const errorOf = (component: string): string | undefined => {
215
248
  const result = run.results.find((r) => r.component === component);
@@ -218,9 +251,10 @@ export async function applyPrSet(options: PrApplyOptions): Promise<{ members: Pr
218
251
  };
219
252
  const byName = new Map(options.components.map((c) => [c.name, c]));
220
253
  const members = options.set.members.map((m): PrMember => {
254
+ if (priorCompleted.has(m.component)) return { ...m, status: "applied" };
221
255
  if (completed.has(m.component)) {
222
256
  const moved = (byName.get(m.component)?.dependsOn ?? [])
223
- .filter((dep) => completed.has(dep) && !sameJson(before[dep], run.componentOutputs[dep]))
257
+ .filter(movedBy)
224
258
  .sort();
225
259
  return { ...m, status: "applied", ...(moved.length > 0 ? { inputsMoved: moved } : {}) };
226
260
  }
@@ -231,10 +265,165 @@ export async function applyPrSet(options: PrApplyOptions): Promise<{ members: Pr
231
265
  return { members, status: run.status === "ok" ? "applied" : "failed", run };
232
266
  }
233
267
 
268
+ const entryKey = (e: Pick<ChangeSetEntry, "member" | "address" | "deposed">): string => `${e.member}\u0000${e.address}\u0000${e.deposed ?? ""}`;
269
+
270
+ /**
271
+ * Why `member`'s fresh plan is not covered by the approved change set, or
272
+ * `undefined` when it is. A member left over from a partial apply is covered
273
+ * when it plans to the digest that was approved, or when every change it
274
+ * plans now is one the approved plan made:
275
+ *
276
+ * - an entry at the same address with the same action, or a `create` or a
277
+ * `delete` where a `replace` was approved (the other half applied);
278
+ * - each attribute it writes was written by the approved entry, to the same
279
+ * value or to one the approved plan knew only after apply. A sensitive
280
+ * attribute carries no value to compare, so it is covered only where the
281
+ * approved entry wrote that attribute as sensitive too. On a `create` left
282
+ * from a `replace`, an attribute the replace did not change is not compared;
283
+ * - each side effect is one the approved plan ran.
284
+ *
285
+ * A member with holes the approved plan did not have is never covered by its
286
+ * entries, since its entries may not say everything it will do.
287
+ */
288
+ export function planCoveredBy(approved: ChangeSetDocument, fresh: ChangeSetDocument, member: string): string | undefined {
289
+ const was = approved.members.find((m) => m.member === member);
290
+ const now = fresh.members.find((m) => m.member === member);
291
+ if (!now) return undefined;
292
+ if (!was) return `${member} was not in the approved plan`;
293
+ if (now.status === "failed" || now.planDigest === null) return `${member} failed to plan${now.error ? `: ${now.error}` : ""}`;
294
+ if (was.planDigest !== null && now.planDigest === was.planDigest) return undefined;
295
+ if (was.status === "failed") return `${member} had no approved plan`;
296
+ const knownHoles = new Set(was.holes.map((h) => h.address));
297
+ const newHole = now.holes.find((h) => !knownHoles.has(h.address));
298
+ if (newHole) return `${member} cannot read ${newHole.address} (${newHole.reason}), so its plan cannot be checked against the approved one`;
299
+
300
+ const approvedEntries = new Map(approved.entries.filter((e) => e.member === member).map((e) => [entryKey(e), e]));
301
+ for (const e of fresh.entries) {
302
+ if (e.member !== member || e.action === "no-op" || e.action === "read") continue;
303
+ // A create-before-destroy replace whose create applied leaves the old
304
+ // object deposed, and the plan now deletes it under a deposed key.
305
+ const a =
306
+ approvedEntries.get(entryKey(e)) ??
307
+ (e.action === "delete" && e.deposed ? approvedEntries.get(entryKey({ ...e, deposed: undefined })) : undefined);
308
+ if (!a) return `${member} now plans to ${e.action} ${e.address}, which the approved plan did not`;
309
+ const halfReplace = a.action === "replace" && (e.action === "create" || e.action === "delete");
310
+ if (a.action !== e.action && !halfReplace) {
311
+ return `${member} now plans to ${e.action} ${e.address}, where the approved plan would ${a.action} it`;
312
+ }
313
+ const approvedAttrs = new Map(a.attributes.map((attr) => [attr.path, attr]));
314
+ for (const attr of e.attributes) {
315
+ const at = `${e.address}.${attr.path}`;
316
+ const prior = approvedAttrs.get(attr.path);
317
+ if (!prior) {
318
+ if (halfReplace && e.action === "create") continue;
319
+ return `${member} now writes ${at}, which the approved plan did not`;
320
+ }
321
+ if (attr.sensitive) {
322
+ if (!prior.sensitive) return `${member} now writes ${at} as sensitive, so it cannot be compared with the approved value`;
323
+ continue;
324
+ }
325
+ if (prior.sensitive) return `${member} writes ${at}, which the approved plan wrote as sensitive, so it cannot be compared`;
326
+ if (prior.unknown) continue;
327
+ if (attr.unknown) return `${member} now writes ${at} with a value known only after apply, where the approved plan knew it`;
328
+ if (!sameJson(attr.after, prior.after)) return `${member} now writes ${at} to a different value than the approved plan`;
329
+ }
330
+ }
331
+ const approvedEffects = new Set(
332
+ (approved.sideEffects ?? []).filter((s) => s.member === member).map((s) => JSON.stringify([s.address, s.type, s.trigger ?? null, s.event ?? null])),
333
+ );
334
+ for (const s of fresh.sideEffects ?? []) {
335
+ if (s.member !== member) continue;
336
+ if (!approvedEffects.has(JSON.stringify([s.address, s.type, s.trigger ?? null, s.event ?? null]))) {
337
+ return `${member} now runs ${s.address}, which the approved plan did not`;
338
+ }
339
+ }
340
+ return undefined;
341
+ }
342
+
343
+ export interface PrResumeOptions {
344
+ components: DriverComponent[];
345
+ /** The fan-out the change derives now. The caller has checked it is the one the record was made from. */
346
+ plan: FanOutPlan;
347
+ registry: CapabilityRegistry;
348
+ env: string;
349
+ vars?: Record<string, unknown>;
350
+ seededOutputs?: Record<string, Record<string, unknown>>;
351
+ /** The approved set, from the attempt record. */
352
+ record: PrApplyRecord;
353
+ /** Components an earlier attempt applied. */
354
+ completed: string[];
355
+ }
356
+
357
+ export type PrResume =
358
+ | {
359
+ ok: true;
360
+ /** The derivation narrowed to what is left. */
361
+ plan: FanOutPlan;
362
+ /** The approved set, carrying fresh plans for the members left. Its digest is the approved one. */
363
+ set: PrPlanSet;
364
+ /** What the fresh plans of the members left came to. */
365
+ fresh: PrPlanSet;
366
+ }
367
+ | {
368
+ ok: false;
369
+ /** Why the approved set does not cover what is left, one line per member. */
370
+ reasons: string[];
371
+ fresh?: PrPlanSet;
372
+ };
373
+
374
+ /**
375
+ * Plan what an apply that failed partway left (#3464): narrow the plan by
376
+ * the components that applied, plan the rest against the outputs the
377
+ * approved plans read, and check each member against the approved change
378
+ * set ({@link planCoveredBy}). Covered, the result is the approved set with
379
+ * the new plans in it, to decide the gate against `record.digest` and hand
380
+ * to {@link applyPrSet}. Not covered, it says why, and the caller plans
381
+ * everything and asks for a fresh approval.
382
+ *
383
+ * The record now travels between CI jobs through a cache (#3543), so its
384
+ * change set has to be the one its digest names: the document's own digest
385
+ * is `record.digest`, and the document gives that digest. The digest binds
386
+ * the entries, holes and side effects {@link planCoveredBy} reads (#3555),
387
+ * so a record cannot widen what a re-planned member may do.
388
+ */
389
+ export async function resumePrSet(options: PrResumeOptions): Promise<PrResume> {
390
+ const { record } = options;
391
+ if (record.changeSet.digest !== record.digest || !verifyChangeSetDigest(record.changeSet)) {
392
+ return { ok: false, reasons: [`the record's change set is not the one its digest ${record.digest} names`] };
393
+ }
394
+ const plan = remainingFanOut(options.plan, options.components, { completed: options.completed });
395
+ const fresh = await planPrSet({
396
+ components: options.components,
397
+ plan,
398
+ registry: options.registry,
399
+ env: options.env,
400
+ ...(options.vars ? { vars: options.vars } : {}),
401
+ seededOutputs: { ...(options.seededOutputs ?? {}), ...options.record.planOutputs },
402
+ });
403
+ const reasons = fresh.doc.members
404
+ .map((m) => planCoveredBy(options.record.changeSet, fresh.doc, m.member))
405
+ .filter((r): r is string => r !== undefined);
406
+ if (reasons.length > 0) return { ok: false, reasons, fresh };
407
+ return {
408
+ ok: true,
409
+ plan,
410
+ fresh,
411
+ set: {
412
+ doc: options.record.changeSet,
413
+ members: options.record.members.map((m) => ({ ...m, status: "planned" as const })),
414
+ plans: fresh.plans,
415
+ outputs: options.record.planOutputs,
416
+ failed: false,
417
+ },
418
+ };
419
+ }
420
+
234
421
  export interface PrReportInput {
235
422
  stage: "plan" | "apply";
236
423
  pr: number | null;
237
424
  env: string;
425
+ /** The workspace member whose pipeline runs the stage (#3465). */
426
+ member?: string;
238
427
  base: string;
239
428
  head: string;
240
429
  op: string;
@@ -247,6 +436,8 @@ export interface PrReportInput {
247
436
  members?: PrMember[];
248
437
  refusal?: PrRefusal;
249
438
  message?: string;
439
+ /** Components an earlier attempt at the approved set applied (#3464). */
440
+ resumed?: string[];
250
441
  }
251
442
 
252
443
  /** The report a stage writes. */
@@ -258,6 +449,7 @@ export function prReport(input: PrReportInput): PrReport {
258
449
  stage: input.stage,
259
450
  pr: input.pr,
260
451
  env: input.env,
452
+ ...(input.member ? { member: input.member } : {}),
261
453
  base: input.base,
262
454
  head: input.head,
263
455
  op: input.op,
@@ -266,6 +458,7 @@ export function prReport(input: PrReportInput): PrReport {
266
458
  status: input.status,
267
459
  ...(input.refusal ? { refusal: input.refusal } : {}),
268
460
  ...(input.message ? { message: input.message } : {}),
461
+ ...(input.resumed ? { resumed: { applied: [...input.resumed].sort() } } : {}),
269
462
  approval: input.approval,
270
463
  selection: {
271
464
  changed: input.plan.order.filter((c) => changed.has(c)),
@@ -115,4 +115,15 @@ describe("wave records", () => {
115
115
  expect(line).toContain("nothing in it was applied");
116
116
  expect(line).toContain("chant approve fan-out release-wave-2 --plan");
117
117
  });
118
+
119
+ test("the approve hint on an unsealed gate has no --sign", () => {
120
+ const line = describeChangedWave(record);
121
+ expect(line).not.toContain("--sign");
122
+ expect(line.endsWith(`--plan ${record.digest}`)).toBe(true);
123
+ });
124
+
125
+ test("the approve hint on a sealed gate ends in --sign", () => {
126
+ const line = describeChangedWave(record, true);
127
+ expect(line.endsWith(`--plan ${record.digest} --sign`)).toBe(true);
128
+ });
118
129
  });
@@ -230,11 +230,16 @@ export function withWaveRecord(records: readonly WaveRecord[], record: WaveRecor
230
230
  /**
231
231
  * The refusal for a wave whose set changed after it was approved: the digest
232
232
  * that was approved, the one planned now, and that nothing in the wave ran.
233
+ * On a sealed gate the approve hint ends in `--sign`.
233
234
  */
234
- export function describeChangedWave(record: Pick<WaveRecord, "wave" | "op" | "gate" | "digest" | "approved">): string {
235
+ export function describeChangedWave(
236
+ record: Pick<WaveRecord, "wave" | "op" | "gate" | "digest" | "approved">,
237
+ /** True when the gate is sealed (`identity.gates`): an approval without `--sign` doesn't count, so the hint says to sign. */
238
+ sealed = false,
239
+ ): string {
235
240
  return (
236
241
  `wave ${record.wave} changed after it was approved, so nothing in it was applied. ` +
237
242
  `approved: ${record.approved ?? "(none)"}; planned now: ${record.digest}. ` +
238
- `Read the new plan, then approve it: chant approve ${record.op} ${record.gate} --plan ${record.digest}`
243
+ `Read the new plan, then approve it: chant approve ${record.op} ${record.gate} --plan ${record.digest}${sealed ? " --sign" : ""}`
239
244
  );
240
245
  }