@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.
- package/dist/change-set.d.ts +30 -8
- package/dist/change-set.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +11 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/pr.d.ts +16 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/changed-files.d.ts +8 -2
- package/dist/components/changed-files.d.ts.map +1 -1
- package/dist/components/fan-out-record.d.ts +24 -0
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +67 -4
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +94 -3
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/gated-waves.d.ts +4 -1
- package/dist/gated-waves.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +7 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +2 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +10 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts +2 -2
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +29 -4
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +32 -6
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.d.ts +15 -1
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/change-set/lifecycle.change-set.golden.json +1 -1
- package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
- package/src/__fixtures__/change-set/warden.plan.json +47 -40
- package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
- package/src/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +1 -1
- package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +2 -2
- package/src/__fixtures__/pr-loop/pr-plan.golden.json +2 -2
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +2 -2
- package/src/build.test.ts +1 -1
- package/src/build.ts +1 -1
- package/src/change-set.test.ts +42 -12
- package/src/change-set.ts +50 -11
- package/src/cli/commands/build.ts +33 -14
- package/src/cli/commands/lint.ts +34 -1
- package/src/cli/handlers/build-lexicon-output.test.ts +102 -0
- package/src/cli/handlers/build.ts +68 -0
- package/src/cli/handlers/fan-out.test.ts +12 -1
- package/src/cli/handlers/fan-out.ts +7 -3
- package/src/cli/handlers/operator.ts +1 -2
- package/src/cli/handlers/pr.ts +223 -35
- package/src/cli/handlers/promote.test.ts +15 -1
- package/src/cli/handlers/promote.ts +5 -3
- package/src/cli/handlers/run.test.ts +215 -1
- package/src/cli/handlers/run.ts +36 -15
- package/src/cli/main.ts +13 -1
- package/src/cli/mcp/never-over-mcp.test.ts +120 -0
- package/src/cli/mcp/op-approve-origin.test.ts +1 -1
- package/src/cli/mcp/op-tools.ts +14 -2
- package/src/cli/mcp/server.test.ts +2 -1
- package/src/cli/registry.ts +7 -0
- package/src/components/changed-files.test.ts +58 -0
- package/src/components/changed-files.ts +19 -4
- package/src/components/fan-out-record.test.ts +47 -2
- package/src/components/fan-out-record.ts +53 -0
- package/src/components/pr-pipeline.ts +96 -6
- package/src/components/pr-run.test.ts +311 -4
- package/src/components/pr-run.ts +198 -5
- package/src/gated-waves.test.ts +11 -0
- package/src/gated-waves.ts +7 -2
- package/src/lexicon.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +4 -3
- package/src/op/gate-name.ts +17 -0
- package/src/op/gate-summary.test.ts +12 -0
- package/src/op/gate-summary.ts +3 -1
- package/src/op/gate.test.ts +15 -1
- package/src/op/gate.ts +21 -4
- package/src/op/index.ts +2 -2
- package/src/op/local-executor.test.ts +35 -0
- package/src/op/local-executor.ts +2 -2
- package/src/op/local-output.test.ts +20 -0
- package/src/op/local-output.ts +4 -4
- package/src/op/runtime.ts +31 -4
- package/src/op/runtimes/local.test.ts +15 -0
- package/src/op/runtimes/local.ts +3 -1
- package/src/pr-loop.test.ts +41 -1
- package/src/pr-loop.ts +41 -10
- package/src/workspace/change-set.schema.json +1 -1
- package/src/workspace/lineage-upgrade-cli.ts +4 -3
- package/src/workspace/lineage-upgrade.test.ts +40 -0
- package/src/workspace/member-pipeline.test.ts +28 -0
- package/src/workspace/member-pipeline.ts +21 -3
- 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 {
|
|
16
|
-
|
|
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
|
-
? [
|
|
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(
|
|
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
|
+
});
|
package/src/components/pr-run.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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)),
|
package/src/gated-waves.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/gated-waves.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|