@intentius/chant 0.61.0 → 0.63.0
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/handlers/operator.d.ts +13 -18
- package/dist/cli/handlers/operator.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/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +117 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +394 -14
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +34 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +232 -1
- package/src/cli/handlers/operator.ts +130 -6
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +134 -39
- package/src/op/activities/lexicon-upgrade.ts +74 -12
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +1013 -1
- package/src/op/activities/reconcile.ts +721 -25
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +614 -0
- package/src/op/gate.ts +190 -24
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +340 -2
- package/src/op/local-executor.ts +190 -32
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtime.ts +2 -0
- package/src/op/runtimes/local.ts +11 -0
- package/src/op/types.ts +19 -0
- package/src/runtime-adapter.ts +17 -3
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
|
@@ -6,7 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import {
|
|
7
7
|
appendGateResolution, appendPendingGate, readGateResolutions, readGateLedger,
|
|
8
8
|
latestResolutionSince, latestPendingGate, isPendingGateExpired,
|
|
9
|
-
resolveApprovalUrl, isApprovalUrl,
|
|
9
|
+
resolveApprovalUrl, isApprovalUrl, latestResolutionForPlan,
|
|
10
|
+
type PendingGateRecord, type GateResolutionRecord,
|
|
10
11
|
} from "./gate-ledger";
|
|
11
12
|
import { readBlobFromPath, writeBlobToPath } from "./git";
|
|
12
13
|
|
|
@@ -253,3 +254,134 @@ describe("lifecycle/gate-ledger — pending facts (#2119)", () => {
|
|
|
253
254
|
});
|
|
254
255
|
});
|
|
255
256
|
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* #2300. `latestResolutionSince` asks "is there a newer approval", which a run
|
|
260
|
+
* answers yes to however much has changed since it was written. That is the
|
|
261
|
+
* rule INTENTIUS/choudoufu#1026 measured applying a renamed resource without
|
|
262
|
+
* complaint. `latestResolutionForPlan` asks "is there an approval of *this*",
|
|
263
|
+
* with recency demoted from the criterion to the tiebreak.
|
|
264
|
+
*/
|
|
265
|
+
describe("latestResolutionForPlan (#2300)", () => {
|
|
266
|
+
const EPOCH = new Date(0).toISOString();
|
|
267
|
+
const PLAN_A = `sha256:${"a".repeat(64)}`;
|
|
268
|
+
const PLAN_B = `sha256:${"b".repeat(64)}`;
|
|
269
|
+
|
|
270
|
+
const resolution = (over: Partial<GateResolutionRecord>): GateResolutionRecord => ({
|
|
271
|
+
version: 1, op: "live-apply", gate: "approve-live-apply", resolvedBy: "alex",
|
|
272
|
+
timestamp: "2026-01-02T00:00:00.000Z", ...over,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a resolution for this plan answers the gate", () => {
|
|
276
|
+
const found = latestResolutionForPlan([resolution({ planDigest: PLAN_A })], "approve-live-apply", EPOCH, PLAN_A);
|
|
277
|
+
expect(found.resolution?.resolvedBy).toBe("alex");
|
|
278
|
+
expect(found.mismatched).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("a resolution for another plan does not, and comes back named", () => {
|
|
282
|
+
const found = latestResolutionForPlan([resolution({ planDigest: PLAN_B })], "approve-live-apply", EPOCH, PLAN_A);
|
|
283
|
+
expect(found.resolution).toBeUndefined();
|
|
284
|
+
expect(found.mismatched?.planDigest).toBe(PLAN_B);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// The migration, and the safe reading of it: a record with no digest proves
|
|
288
|
+
// someone approved something, and nothing about what.
|
|
289
|
+
test("a resolution written before #2300 never matches a plan-bound gate", () => {
|
|
290
|
+
const found = latestResolutionForPlan([resolution({})], "approve-live-apply", EPOCH, PLAN_A);
|
|
291
|
+
expect(found.resolution).toBeUndefined();
|
|
292
|
+
expect(found.mismatched).toBeDefined();
|
|
293
|
+
expect(found.mismatched?.planDigest).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Recency is the tiebreak, not the criterion: a newer approval of the wrong
|
|
297
|
+
// plan does not shadow an older approval of the right one.
|
|
298
|
+
test("an older resolution for this plan beats a newer one for another", () => {
|
|
299
|
+
const found = latestResolutionForPlan(
|
|
300
|
+
[
|
|
301
|
+
resolution({ planDigest: PLAN_A, resolvedBy: "right", timestamp: "2026-01-02T00:00:00.000Z" }),
|
|
302
|
+
resolution({ planDigest: PLAN_B, resolvedBy: "wrong", timestamp: "2026-01-09T00:00:00.000Z" }),
|
|
303
|
+
],
|
|
304
|
+
"approve-live-apply", EPOCH, PLAN_A,
|
|
305
|
+
);
|
|
306
|
+
expect(found.resolution?.resolvedBy).toBe("right");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("among several for this plan, the newest wins", () => {
|
|
310
|
+
const found = latestResolutionForPlan(
|
|
311
|
+
[
|
|
312
|
+
resolution({ planDigest: PLAN_A, resolvedBy: "first", timestamp: "2026-01-02T00:00:00.000Z" }),
|
|
313
|
+
resolution({ planDigest: PLAN_A, resolvedBy: "second", timestamp: "2026-01-03T00:00:00.000Z" }),
|
|
314
|
+
],
|
|
315
|
+
"approve-live-apply", EPOCH, PLAN_A,
|
|
316
|
+
);
|
|
317
|
+
expect(found.resolution?.resolvedBy).toBe("second");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("the staleness rule still applies — a resolution older than the pending fact is no answer to it", () => {
|
|
321
|
+
const found = latestResolutionForPlan(
|
|
322
|
+
[resolution({ planDigest: PLAN_A, timestamp: "2026-01-01T00:00:00.000Z" })],
|
|
323
|
+
"approve-live-apply", "2026-01-05T00:00:00.000Z", PLAN_A,
|
|
324
|
+
);
|
|
325
|
+
expect(found.resolution).toBeUndefined();
|
|
326
|
+
expect(found.mismatched).toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("a gate that binds no plan decides exactly as latestResolutionSince does", () => {
|
|
330
|
+
const records = [resolution({}), resolution({ resolvedBy: "newer", timestamp: "2026-01-04T00:00:00.000Z" })];
|
|
331
|
+
expect(latestResolutionForPlan(records, "approve-live-apply", EPOCH, undefined).resolution?.resolvedBy)
|
|
332
|
+
.toBe(latestResolutionSince(records, "approve-live-apply", EPOCH)?.resolvedBy);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("another gate's resolutions are not read as this one's", () => {
|
|
336
|
+
const found = latestResolutionForPlan(
|
|
337
|
+
[resolution({ gate: "approve-live-adopt", planDigest: PLAN_A })],
|
|
338
|
+
"approve-live-apply", EPOCH, PLAN_A,
|
|
339
|
+
);
|
|
340
|
+
expect(found.resolution).toBeUndefined();
|
|
341
|
+
expect(found.mismatched).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A `planDigest` that is present but not a string is a malformed line, not a
|
|
347
|
+
* line with a field to ignore (#2300) — ignoring it would demote a plan-bound
|
|
348
|
+
* record to a digest-less one, which is the shape a plan-bound gate refuses.
|
|
349
|
+
*/
|
|
350
|
+
describe("readGateLedger — a corrupted planDigest is malformed (#2300)", () => {
|
|
351
|
+
test("a non-string planDigest is counted, not read as an approval of nothing", async () => {
|
|
352
|
+
await withTestDir(async (dir) => {
|
|
353
|
+
await initRepo(dir);
|
|
354
|
+
await writeBlobToPath(
|
|
355
|
+
"_gates", "live-apply.jsonl",
|
|
356
|
+
JSON.stringify({
|
|
357
|
+
version: 1, op: "live-apply", gate: "g", resolvedBy: "alex",
|
|
358
|
+
timestamp: "2026-01-01T00:00:00.000Z", planDigest: { sha: "…" },
|
|
359
|
+
}),
|
|
360
|
+
"hand-written",
|
|
361
|
+
{ cwd: dir },
|
|
362
|
+
);
|
|
363
|
+
const { resolutions, malformed } = await readGateLedger("live-apply", { cwd: dir });
|
|
364
|
+
expect(malformed).toBe(1);
|
|
365
|
+
expect(resolutions).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("a string planDigest round-trips onto both kinds of record", async () => {
|
|
370
|
+
await withTestDir(async (dir) => {
|
|
371
|
+
await initRepo(dir);
|
|
372
|
+
const digest = `sha256:${"a".repeat(64)}`;
|
|
373
|
+
await appendPendingGate(
|
|
374
|
+
{ op: "live-apply", gate: "g", timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2026-01-03T00:00:00.000Z", planDigest: digest },
|
|
375
|
+
{ cwd: dir },
|
|
376
|
+
);
|
|
377
|
+
await appendGateResolution(
|
|
378
|
+
{ op: "live-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-02T00:00:00.000Z", planDigest: digest },
|
|
379
|
+
{ cwd: dir },
|
|
380
|
+
);
|
|
381
|
+
const { resolutions, pending, malformed } = await readGateLedger("live-apply", { cwd: dir });
|
|
382
|
+
expect(malformed).toBe(0);
|
|
383
|
+
expect(pending[0].planDigest).toBe(digest);
|
|
384
|
+
expect(resolutions[0].planDigest).toBe(digest);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* `chant approve` locally can write one, the same trust boundary a local
|
|
23
23
|
* commit already has.
|
|
24
24
|
*
|
|
25
|
+
* Since #2300 both halves also carry a plan identity (`./plan-digest.ts`): a
|
|
26
|
+
* run records the plan it reached the gate with, `chant approve` records the
|
|
27
|
+
* plan it approves, and {@link latestResolutionForPlan} matches on that
|
|
28
|
+
* rather than on recency alone. Before it, an approval authorised the next
|
|
29
|
+
* run of an op rather than the plan its approver had read
|
|
30
|
+
* (INTENTIUS/choudoufu#1026).
|
|
31
|
+
*
|
|
25
32
|
* Since #2119 the file carries both halves of the loop. A `gate` step the
|
|
26
33
|
* local executor reaches (`../op/local-executor.ts`) appends a
|
|
27
34
|
* {@link PendingGateRecord} and ends that run with status `gated`; `chant
|
|
@@ -119,6 +126,18 @@ export interface GateResolutionRecord {
|
|
|
119
126
|
* URL — see {@link isApprovalUrl}.
|
|
120
127
|
*/
|
|
121
128
|
url?: string;
|
|
129
|
+
/**
|
|
130
|
+
* The plan this approval is for (#2300) — `computePlanDigest`'s output
|
|
131
|
+
* (`./plan-digest.ts`), normally copied off the {@link PendingGateRecord}
|
|
132
|
+
* this resolution answers.
|
|
133
|
+
*
|
|
134
|
+
* Absent on every resolution written before #2300, and on one written for a
|
|
135
|
+
* gate that binds no plan. Absent is not a wildcard: {@link
|
|
136
|
+
* latestResolutionForPlan} refuses a digest-less resolution against a
|
|
137
|
+
* plan-bound gate rather than letting it through, because the only thing
|
|
138
|
+
* such a record proves is that somebody approved *something*.
|
|
139
|
+
*/
|
|
140
|
+
planDigest?: string;
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
export type GateResolutionInput = Omit<GateResolutionRecord, "version" | "kind">;
|
|
@@ -154,6 +173,16 @@ export interface PendingGateRecord {
|
|
|
154
173
|
expiresAt: string;
|
|
155
174
|
/** The address approval happens at, when the run knew one — see {@link resolveApprovalUrl}. */
|
|
156
175
|
url?: string;
|
|
176
|
+
/**
|
|
177
|
+
* The plan the run reached this gate with (#2300) — `computePlanDigest`'s
|
|
178
|
+
* output (`./plan-digest.ts`). This is what `chant approve` copies onto the
|
|
179
|
+
* resolution by default, so approving the standing fact approves the plan
|
|
180
|
+
* the approver was shown rather than the next run's.
|
|
181
|
+
*
|
|
182
|
+
* Absent when the gate binds no plan (a component gate, an authored `gate`
|
|
183
|
+
* step with no `plan`), which is the shape every gate had before #2300.
|
|
184
|
+
*/
|
|
185
|
+
planDigest?: string;
|
|
157
186
|
}
|
|
158
187
|
|
|
159
188
|
export type PendingGateInput = Omit<PendingGateRecord, "version" | "kind">;
|
|
@@ -262,6 +291,16 @@ export async function readGateLedger(
|
|
|
262
291
|
malformed++;
|
|
263
292
|
continue;
|
|
264
293
|
}
|
|
294
|
+
// #2300: a `planDigest` that is present but not a string is a
|
|
295
|
+
// malformed line, not a line with a field to ignore. Ignoring it would
|
|
296
|
+
// silently demote a plan-bound record to a digest-less one, which is
|
|
297
|
+
// the shape `latestResolutionForPlan` refuses — a corrupted approval
|
|
298
|
+
// must not read as an approval of anything at all.
|
|
299
|
+
const rawDigest: unknown = (parsed as { planDigest?: unknown }).planDigest;
|
|
300
|
+
if (rawDigest !== undefined && typeof rawDigest !== "string") {
|
|
301
|
+
malformed++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
265
304
|
if (parsed.kind === "pending") {
|
|
266
305
|
if (typeof parsed.expiresAt !== "string") {
|
|
267
306
|
malformed++;
|
|
@@ -319,3 +358,72 @@ export function latestResolutionSince(
|
|
|
319
358
|
}
|
|
320
359
|
return latest;
|
|
321
360
|
}
|
|
361
|
+
|
|
362
|
+
/** What {@link latestResolutionForPlan} found for the plan a run is holding. */
|
|
363
|
+
export interface PlanBoundResolution {
|
|
364
|
+
/** The resolution that answers this gate for this plan. Absent when nothing does. */
|
|
365
|
+
resolution?: GateResolutionRecord;
|
|
366
|
+
/**
|
|
367
|
+
* Present instead of {@link PlanBoundResolution.resolution} when a
|
|
368
|
+
* resolution stands for this gate but for a different plan — the newest
|
|
369
|
+
* such record, so a refusal can name who approved what and when. Its
|
|
370
|
+
* `planDigest` is `undefined` for a record written before #2300.
|
|
371
|
+
*/
|
|
372
|
+
mismatched?: GateResolutionRecord;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The resolution that answers `gate` for the plan `planDigest` identifies
|
|
377
|
+
* (#2300).
|
|
378
|
+
*
|
|
379
|
+
* The criterion is the digest; recency is only the tiebreak between several
|
|
380
|
+
* resolutions that all match it. That inversion is the whole point of the
|
|
381
|
+
* issue: {@link latestResolutionSince} asks "is there a newer approval",
|
|
382
|
+
* which a run answers yes to no matter what has changed since, and this asks
|
|
383
|
+
* "is there an approval of *this*".
|
|
384
|
+
*
|
|
385
|
+
* - `planDigest` `undefined` — the gate binds no plan (a component gate, a
|
|
386
|
+
* `gate` step authored with no `plan`). Falls straight through to
|
|
387
|
+
* {@link latestResolutionSince}: gates that never claimed to bind a plan
|
|
388
|
+
* behave exactly as they did before #2300.
|
|
389
|
+
* - A resolution whose `planDigest` equals `planDigest` answers the gate.
|
|
390
|
+
* - A resolution for a different plan does not, and comes back as
|
|
391
|
+
* `mismatched` so the caller can name both digests.
|
|
392
|
+
* - A resolution with no `planDigest` at all — every record written before
|
|
393
|
+
* #2300 — does not either, and comes back as `mismatched` with an absent
|
|
394
|
+
* `planDigest`. This is the safe reading of the migration: such a record
|
|
395
|
+
* proves someone approved something, and nothing about what. Accepting it
|
|
396
|
+
* once would silently apply the very change this check exists to catch, on
|
|
397
|
+
* exactly the estates that have been running longest. The cost is one
|
|
398
|
+
* further `chant approve` per gate after the upgrade, which the refusal
|
|
399
|
+
* says out loud.
|
|
400
|
+
*/
|
|
401
|
+
export function latestResolutionForPlan(
|
|
402
|
+
records: GateResolutionRecord[],
|
|
403
|
+
gate: string,
|
|
404
|
+
sinceIso: string,
|
|
405
|
+
planDigest: string | undefined,
|
|
406
|
+
): PlanBoundResolution {
|
|
407
|
+
if (planDigest === undefined) {
|
|
408
|
+
const resolution = latestResolutionSince(records, gate, sinceIso);
|
|
409
|
+
return resolution ? { resolution } : {};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const since = new Date(sinceIso).getTime();
|
|
413
|
+
let matched: GateResolutionRecord | undefined;
|
|
414
|
+
let mismatched: GateResolutionRecord | undefined;
|
|
415
|
+
for (const r of records) {
|
|
416
|
+
if (r.gate !== gate) continue;
|
|
417
|
+
if (new Date(r.timestamp).getTime() < since) continue;
|
|
418
|
+
const newest = (best: GateResolutionRecord | undefined) =>
|
|
419
|
+
!best || new Date(r.timestamp).getTime() >= new Date(best.timestamp).getTime();
|
|
420
|
+
if (r.planDigest === planDigest) {
|
|
421
|
+
if (newest(matched)) matched = r;
|
|
422
|
+
} else if (newest(mismatched)) {
|
|
423
|
+
mismatched = r;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (matched) return { resolution: matched };
|
|
428
|
+
return mismatched ? { mismatched } : {};
|
|
429
|
+
}
|
|
@@ -420,8 +420,18 @@ describe("lifecycle/git", () => {
|
|
|
420
420
|
}
|
|
421
421
|
});
|
|
422
422
|
|
|
423
|
-
|
|
424
|
-
|
|
423
|
+
/**
|
|
424
|
+
* Rewritten for #2309's review. This used to assert that operator B's push
|
|
425
|
+
* was *rejected*: B had no local `chant/lifecycle`, so its write built an
|
|
426
|
+
* unrelated root commit, and the lease was the only thing standing between
|
|
427
|
+
* that fork and A's history on the remote.
|
|
428
|
+
*
|
|
429
|
+
* A write that has to be caught by a lease is the bug, not the contract.
|
|
430
|
+
* `writeBlobToPath` now brings the branch in before it creates one, so B
|
|
431
|
+
* never forks: it appends to A's history and pushes cleanly, and both
|
|
432
|
+
* snapshots survive. The genuine stale-lease case is the test below.
|
|
433
|
+
*/
|
|
434
|
+
test("a second writer with no local ledger appends to the first's history rather than forking", async () => {
|
|
425
435
|
const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
|
|
426
436
|
const cloneB = join(tmpdir(), `chant-state-clone-b-${Date.now()}-${Math.random()}`);
|
|
427
437
|
try {
|
|
@@ -433,9 +443,43 @@ describe("lifecycle/git", () => {
|
|
|
433
443
|
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: cloneA });
|
|
434
444
|
expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
|
|
435
445
|
|
|
436
|
-
// Operator B
|
|
437
|
-
|
|
438
|
-
|
|
446
|
+
// Operator B has never seen `chant/lifecycle`.
|
|
447
|
+
expect(git(["rev-parse", "--verify", "refs/heads/chant/lifecycle"], cloneB).exitCode).not.toBe(0);
|
|
448
|
+
await writeSnapshot("staging", "gcp", JSON.stringify({ b: 2 }), { cwd: cloneB });
|
|
449
|
+
expect(await pushLifecycle({ cwd: cloneB })).toBe(true);
|
|
450
|
+
|
|
451
|
+
// Neither snapshot was lost.
|
|
452
|
+
expect(await readSnapshot("prod", "aws", { cwd: cloneB })).toBe(JSON.stringify({ a: 1 }));
|
|
453
|
+
expect(await readSnapshot("staging", "gcp", { cwd: cloneB })).toBe(JSON.stringify({ b: 2 }));
|
|
454
|
+
} finally {
|
|
455
|
+
await cleanup();
|
|
456
|
+
const { rm } = await import("node:fs/promises");
|
|
457
|
+
await rm(cloneB, { recursive: true, force: true });
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("concurrent write rejected: second push throws StaleLifecycleBranchError", async () => {
|
|
462
|
+
const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
|
|
463
|
+
const cloneB = join(tmpdir(), `chant-state-clone-b-${Date.now()}-${Math.random()}`);
|
|
464
|
+
try {
|
|
465
|
+
git(["clone", "-q", remotePath, cloneB], tmpdir());
|
|
466
|
+
git(["config", "user.email", "test@chant.dev"], cloneB);
|
|
467
|
+
git(["config", "user.name", "Test"], cloneB);
|
|
468
|
+
|
|
469
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: cloneA });
|
|
470
|
+
expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
|
|
471
|
+
|
|
472
|
+
// B takes a copy of the branch, so it has local history and a
|
|
473
|
+
// remote-tracking ref pinned to what it saw.
|
|
474
|
+
git(["fetch", "-q", "origin", "chant/lifecycle:chant/lifecycle"], cloneB);
|
|
475
|
+
|
|
476
|
+
// A moves the remote on again, behind B's back.
|
|
477
|
+
git(["fetch", "-q", "origin", "+refs/heads/chant/lifecycle:refs/remotes/origin/chant/lifecycle"], cloneA);
|
|
478
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 2 }), { cwd: cloneA });
|
|
479
|
+
expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
|
|
480
|
+
|
|
481
|
+
// B appends to the tip it knows and pushes against a lease that no
|
|
482
|
+
// longer matches the remote.
|
|
439
483
|
await writeSnapshot("staging", "gcp", JSON.stringify({ b: 2 }), { cwd: cloneB });
|
|
440
484
|
await expect(pushLifecycle({ cwd: cloneB })).rejects.toBeInstanceOf(StaleLifecycleBranchError);
|
|
441
485
|
} finally {
|