@intentius/chant 0.52.2 → 0.53.1
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/cdk/advise.d.ts +29 -0
- package/dist/cdk/advise.d.ts.map +1 -0
- package/dist/cdk/assembly.d.ts +38 -0
- package/dist/cdk/assembly.d.ts.map +1 -0
- package/dist/cdk/graph.d.ts +68 -0
- package/dist/cdk/graph.d.ts.map +1 -0
- package/dist/cdk/tier-map.d.ts +44 -0
- package/dist/cdk/tier-map.d.ts.map +1 -0
- package/dist/cdk/types.d.ts +114 -0
- package/dist/cdk/types.d.ts.map +1 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/carve.d.ts +48 -6
- package/dist/cli/commands/carve.d.ts.map +1 -1
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/carve.d.ts +5 -4
- package/dist/cli/handlers/carve.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +10 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +37 -4
- package/dist/cli/handlers/op-progress.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +61 -1
- 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 +9 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/identity.d.ts +196 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +81 -1
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +43 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +17 -66
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +1 -1
- package/dist/terraform/carve-provider.d.ts +142 -0
- package/dist/terraform/carve-provider.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +36 -3
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/emit-source.d.ts +25 -0
- package/dist/terraform/emit-source.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +11 -1
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/providers/aws.d.ts +19 -0
- package/dist/terraform/providers/aws.d.ts.map +1 -0
- package/dist/terraform/providers/gcp.d.ts +41 -0
- package/dist/terraform/providers/gcp.d.ts.map +1 -0
- package/dist/terraform/providers/index.d.ts +15 -0
- package/dist/terraform/providers/index.d.ts.map +1 -0
- package/dist/terraform/providers/kubernetes.d.ts +29 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +70 -4
- package/dist/terraform/score.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +30 -35
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +6 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
- package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
- package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
- package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
- package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
- package/src/cdk/advise.test.ts +208 -0
- package/src/cdk/advise.ts +44 -0
- package/src/cdk/assembly.ts +133 -0
- package/src/cdk/graph.test.ts +206 -0
- package/src/cdk/graph.ts +525 -0
- package/src/cdk/tier-map.ts +71 -0
- package/src/cdk/types.ts +115 -0
- package/src/cli/commands/carve-apply.ts +20 -4
- package/src/cli/commands/carve-bridge.ts +9 -5
- package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
- package/src/cli/commands/carve-emit-provider.test.ts +207 -0
- package/src/cli/commands/carve-emit.ts +28 -24
- package/src/cli/commands/carve.ts +139 -36
- package/src/cli/handlers/carve-emit.ts +8 -3
- package/src/cli/handlers/carve.ts +8 -7
- package/src/cli/handlers/lifecycle.test.ts +187 -1
- package/src/cli/handlers/lifecycle.ts +125 -1
- package/src/cli/handlers/op-progress.test.ts +67 -1
- package/src/cli/handlers/op-progress.ts +70 -14
- package/src/cli/handlers/operator.test.ts +302 -1
- package/src/cli/handlers/operator.ts +193 -5
- package/src/cli/handlers/run.ts +11 -13
- package/src/cli/main.test.ts +19 -0
- package/src/cli/main.ts +38 -10
- package/src/cli/registry.ts +9 -1
- package/src/identity.test.ts +199 -0
- package/src/identity.ts +346 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +53 -0
- package/src/lifecycle/converge-ledger.test.ts +110 -0
- package/src/lifecycle/converge-ledger.ts +110 -2
- package/src/lifecycle/gate-ledger.test.ts +81 -1
- package/src/lifecycle/gate-ledger.ts +64 -1
- package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
- package/src/terraform/adopt-state.test.ts +131 -0
- package/src/terraform/adopt-state.ts +19 -167
- package/src/terraform/aws-resources.test.ts +38 -22
- package/src/terraform/aws-resources.ts +1 -1
- package/src/terraform/carve-provider.test.ts +155 -0
- package/src/terraform/carve-provider.ts +237 -0
- package/src/terraform/carve.test.ts +55 -1
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/emit-source.ts +39 -0
- package/src/terraform/graduate.test.ts +37 -0
- package/src/terraform/graduate.ts +55 -7
- package/src/terraform/graph.ts +3 -3
- package/src/terraform/providers/aws.ts +169 -0
- package/src/terraform/providers/gcp.test.ts +228 -0
- package/src/terraform/providers/gcp.ts +329 -0
- package/src/terraform/providers/index.ts +21 -0
- package/src/terraform/providers/kubernetes.ts +224 -0
- package/src/terraform/score.ts +111 -25
- package/src/terraform/tier-map.ts +45 -107
- package/src/terraform/types.ts +6 -0
|
@@ -54,7 +54,7 @@ vi.mock("../../lifecycle/git", async () => {
|
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
// Imported after the mocks above are registered.
|
|
57
|
-
const { runOperator, runOperatorStatus, runApprove } = await import("./operator");
|
|
57
|
+
const { runOperator, runOperatorStatus, runOperatorLog, runApprove } = await import("./operator");
|
|
58
58
|
|
|
59
59
|
function ctx(args: Partial<ParsedArgs>) {
|
|
60
60
|
return {
|
|
@@ -180,6 +180,77 @@ describe("runOperatorStatus", () => {
|
|
|
180
180
|
logSpy.mockRestore();
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
// #2028 — the pending fact is the one a human has to act on, so it is the
|
|
184
|
+
// one that has to carry a link. Before this the row was {rule, op, gate}
|
|
185
|
+
// and the only affordance a renderer could offer was a shell command.
|
|
186
|
+
test("a gated outcome's approval url rides onto the pending-gate row", async () => {
|
|
187
|
+
discoverConvergeOpsMock.mockResolvedValue({
|
|
188
|
+
ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
|
|
189
|
+
errors: [],
|
|
190
|
+
});
|
|
191
|
+
readConvergeLedgerMock.mockResolvedValue({
|
|
192
|
+
records: [
|
|
193
|
+
{
|
|
194
|
+
version: 1,
|
|
195
|
+
op: "staging-converge",
|
|
196
|
+
env: "staging",
|
|
197
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
198
|
+
firedRuleIds: ["drift-apply"],
|
|
199
|
+
outcomes: [{
|
|
200
|
+
ruleId: "drift-apply",
|
|
201
|
+
action: "gated",
|
|
202
|
+
op: "fountain-apply",
|
|
203
|
+
gateName: "rollout-gate",
|
|
204
|
+
url: "https://github.com/INTENTIUS/chant/pull/2028",
|
|
205
|
+
}],
|
|
206
|
+
summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
|
|
207
|
+
log: "converge(staging): ...",
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
malformed: 0,
|
|
211
|
+
});
|
|
212
|
+
readLeaseMock.mockResolvedValue({ sha: null, record: undefined });
|
|
213
|
+
readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 });
|
|
214
|
+
|
|
215
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
216
|
+
await runOperatorStatus(ctx({ json: true }));
|
|
217
|
+
const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
218
|
+
expect(printed[0].pendingGates).toEqual([
|
|
219
|
+
{ rule: "drift-apply", op: "fountain-apply", gate: "rollout-gate", url: "https://github.com/INTENTIUS/chant/pull/2028" },
|
|
220
|
+
]);
|
|
221
|
+
logSpy.mockRestore();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("the human render prints the address under the pending gate", async () => {
|
|
225
|
+
discoverConvergeOpsMock.mockResolvedValue({
|
|
226
|
+
ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
|
|
227
|
+
errors: [],
|
|
228
|
+
});
|
|
229
|
+
readConvergeLedgerMock.mockResolvedValue({
|
|
230
|
+
records: [
|
|
231
|
+
{
|
|
232
|
+
version: 1,
|
|
233
|
+
op: "staging-converge",
|
|
234
|
+
env: "staging",
|
|
235
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
236
|
+
firedRuleIds: ["drift-apply"],
|
|
237
|
+
outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate", url: "https://pr.example/1" }],
|
|
238
|
+
summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
|
|
239
|
+
log: "converge(staging): ...",
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
malformed: 0,
|
|
243
|
+
});
|
|
244
|
+
readLeaseMock.mockResolvedValue({ sha: null, record: undefined });
|
|
245
|
+
readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 });
|
|
246
|
+
|
|
247
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
248
|
+
await runOperatorStatus(ctx({}));
|
|
249
|
+
const out = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
250
|
+
expect(out).toContain("approve at: https://pr.example/1");
|
|
251
|
+
logSpy.mockRestore();
|
|
252
|
+
});
|
|
253
|
+
|
|
183
254
|
test("a gate resolved after the tick that recorded it is no longer pending", async () => {
|
|
184
255
|
discoverConvergeOpsMock.mockResolvedValue({
|
|
185
256
|
ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
|
|
@@ -239,6 +310,57 @@ describe("runApprove", () => {
|
|
|
239
310
|
errSpy.mockRestore();
|
|
240
311
|
});
|
|
241
312
|
|
|
313
|
+
// #2028 — the resolution's link is typed, so a reader is not sniffing
|
|
314
|
+
// free-text `note` for something that looks like a URL.
|
|
315
|
+
test("--url is recorded typed, alongside --note's prose", async () => {
|
|
316
|
+
vi.stubEnv("GITHUB_REF_NAME", "");
|
|
317
|
+
vi.stubEnv("GITHUB_REPOSITORY", "");
|
|
318
|
+
vi.stubEnv("CI_MERGE_REQUEST_PROJECT_URL", "");
|
|
319
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
320
|
+
commit: "sha",
|
|
321
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", url: "https://github.com/org/repo/pull/9" },
|
|
322
|
+
});
|
|
323
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
324
|
+
|
|
325
|
+
const code = await runApprove(ctx({
|
|
326
|
+
path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex",
|
|
327
|
+
note: "rolled staging first", url: "https://github.com/org/repo/pull/9",
|
|
328
|
+
}));
|
|
329
|
+
|
|
330
|
+
expect(code).toBe(0);
|
|
331
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(
|
|
332
|
+
expect.objectContaining({ note: "rolled staging first", url: "https://github.com/org/repo/pull/9" }),
|
|
333
|
+
);
|
|
334
|
+
errSpy.mockRestore();
|
|
335
|
+
vi.unstubAllEnvs();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("without --url, the surrounding PR/MR job is itself the address", async () => {
|
|
339
|
+
vi.stubEnv("GITHUB_SERVER_URL", "https://github.com");
|
|
340
|
+
vi.stubEnv("GITHUB_REPOSITORY", "INTENTIUS/chant");
|
|
341
|
+
vi.stubEnv("GITHUB_REF_NAME", "2028/merge");
|
|
342
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
343
|
+
commit: "sha",
|
|
344
|
+
record: { version: 1, op: "fountain-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
345
|
+
});
|
|
346
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
347
|
+
|
|
348
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "g", actor: "alex" }))).toBe(0);
|
|
349
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(
|
|
350
|
+
expect.objectContaining({ url: "https://github.com/INTENTIUS/chant/pull/2028" }),
|
|
351
|
+
);
|
|
352
|
+
errSpy.mockRestore();
|
|
353
|
+
vi.unstubAllEnvs();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("a --url that is not an absolute http/https link is refused, and nothing is written", async () => {
|
|
357
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
358
|
+
const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "g", url: "org/repo/pull/1" }));
|
|
359
|
+
expect(code).toBe(1);
|
|
360
|
+
expect(appendGateResolutionMock).not.toHaveBeenCalled();
|
|
361
|
+
errSpy.mockRestore();
|
|
362
|
+
});
|
|
363
|
+
|
|
242
364
|
test("warns (but still records) when the op isn't among discovered *.op.ts declarations", async () => {
|
|
243
365
|
discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
|
|
244
366
|
appendGateResolutionMock.mockResolvedValue({
|
|
@@ -253,3 +375,182 @@ describe("runApprove", () => {
|
|
|
253
375
|
errSpy.mockRestore();
|
|
254
376
|
});
|
|
255
377
|
});
|
|
378
|
+
|
|
379
|
+
// ── chant operator log (#2029) ────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
describe("runOperatorLog", () => {
|
|
382
|
+
function tick(over: Record<string, unknown> = {}) {
|
|
383
|
+
return {
|
|
384
|
+
version: 1,
|
|
385
|
+
id: "11111111-2222-3333-4444-555555555555",
|
|
386
|
+
op: "staging-converge",
|
|
387
|
+
env: "staging",
|
|
388
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
389
|
+
firedRuleIds: [],
|
|
390
|
+
outcomes: [],
|
|
391
|
+
summary: { drifted: 0, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0 },
|
|
392
|
+
log: "converge(staging): drifted=0",
|
|
393
|
+
...over,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
beforeEach(() => {
|
|
398
|
+
discoverConvergeOpsMock.mockResolvedValue({
|
|
399
|
+
ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
|
|
400
|
+
errors: [],
|
|
401
|
+
});
|
|
402
|
+
readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("--json emits the whole tick history, not just the newest row", async () => {
|
|
406
|
+
readConvergeLedgerMock.mockResolvedValue({
|
|
407
|
+
records: [
|
|
408
|
+
tick({ id: "t1", timestamp: "2026-01-01T00:00:00.000Z" }),
|
|
409
|
+
tick({ id: "t2", timestamp: "2026-01-01T01:00:00.000Z" }),
|
|
410
|
+
tick({ id: "t3", timestamp: "2026-01-01T02:00:00.000Z" }),
|
|
411
|
+
],
|
|
412
|
+
malformed: 0,
|
|
413
|
+
});
|
|
414
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
415
|
+
expect(await runOperatorLog(ctx({ json: true }))).toBe(0);
|
|
416
|
+
|
|
417
|
+
const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
418
|
+
expect(printed.entries.map((e: { record: { id: string } }) => e.record.id)).toEqual(["t1", "t2", "t3"]);
|
|
419
|
+
expect(printed.malformed).toEqual({ converge: 0, gates: 0 });
|
|
420
|
+
logSpy.mockRestore();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("carries the malformed-line count, so a short timeline is never silently short", async () => {
|
|
424
|
+
readConvergeLedgerMock.mockResolvedValue({ records: [tick()], malformed: 3 });
|
|
425
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
426
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
427
|
+
|
|
428
|
+
await runOperatorLog(ctx({ json: true }));
|
|
429
|
+
expect(JSON.parse(logSpy.mock.calls[0][0] as string).malformed.converge).toBe(3);
|
|
430
|
+
|
|
431
|
+
logSpy.mockClear();
|
|
432
|
+
await runOperatorLog(ctx({}));
|
|
433
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain("3 ledger line(s) were unreadable");
|
|
434
|
+
|
|
435
|
+
logSpy.mockRestore();
|
|
436
|
+
errSpy.mockRestore();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("gate resolutions are merged into the timeline, in timestamp order after the tick that gated", async () => {
|
|
440
|
+
readConvergeLedgerMock.mockResolvedValue({
|
|
441
|
+
records: [
|
|
442
|
+
tick({
|
|
443
|
+
id: "t1",
|
|
444
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
445
|
+
outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate", url: "https://pr.example/1" }],
|
|
446
|
+
}),
|
|
447
|
+
tick({ id: "t2", timestamp: "2026-01-03T00:00:00.000Z" }),
|
|
448
|
+
],
|
|
449
|
+
malformed: 0,
|
|
450
|
+
});
|
|
451
|
+
readGateResolutionsMock.mockResolvedValue({
|
|
452
|
+
records: [{ version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-02T00:00:00.000Z", url: "https://pr.example/1" }],
|
|
453
|
+
malformed: 0,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
457
|
+
await runOperatorLog(ctx({ json: true }));
|
|
458
|
+
const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
459
|
+
expect(printed.entries.map((e: { kind: string }) => e.kind)).toEqual(["tick", "gate-resolution", "tick"]);
|
|
460
|
+
expect(readGateResolutionsMock).toHaveBeenCalledWith("fountain-apply", expect.anything());
|
|
461
|
+
logSpy.mockRestore();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("--since drops everything older, --limit keeps the newest n (still oldest-first)", async () => {
|
|
465
|
+
readConvergeLedgerMock.mockResolvedValue({
|
|
466
|
+
records: [
|
|
467
|
+
tick({ id: "t1", timestamp: "2026-01-01T00:00:00.000Z" }),
|
|
468
|
+
tick({ id: "t2", timestamp: "2026-01-02T00:00:00.000Z" }),
|
|
469
|
+
tick({ id: "t3", timestamp: "2026-01-03T00:00:00.000Z" }),
|
|
470
|
+
tick({ id: "t4", timestamp: "2026-01-04T00:00:00.000Z" }),
|
|
471
|
+
],
|
|
472
|
+
malformed: 0,
|
|
473
|
+
});
|
|
474
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
475
|
+
|
|
476
|
+
await runOperatorLog(ctx({ json: true, since: "2026-01-02T00:00:00.000Z" }));
|
|
477
|
+
expect(JSON.parse(logSpy.mock.calls[0][0] as string).entries.map((e: { record: { id: string } }) => e.record.id))
|
|
478
|
+
.toEqual(["t2", "t3", "t4"]);
|
|
479
|
+
|
|
480
|
+
logSpy.mockClear();
|
|
481
|
+
await runOperatorLog(ctx({ json: true, limit: 2 }));
|
|
482
|
+
expect(JSON.parse(logSpy.mock.calls[0][0] as string).entries.map((e: { record: { id: string } }) => e.record.id))
|
|
483
|
+
.toEqual(["t3", "t4"]);
|
|
484
|
+
|
|
485
|
+
logSpy.mockRestore();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("--op restricts to one ConvergeOp", async () => {
|
|
489
|
+
discoverConvergeOpsMock.mockResolvedValue({
|
|
490
|
+
ops: [
|
|
491
|
+
{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } },
|
|
492
|
+
{ config: { name: "other-converge", searchAttributes: { Env: "staging" } } },
|
|
493
|
+
],
|
|
494
|
+
errors: [],
|
|
495
|
+
});
|
|
496
|
+
readConvergeLedgerMock.mockResolvedValue({
|
|
497
|
+
records: [tick({ id: "t1" }), tick({ id: "t2", op: "other-converge" })],
|
|
498
|
+
malformed: 0,
|
|
499
|
+
});
|
|
500
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
501
|
+
await runOperatorLog(ctx({ json: true, op: "other-converge" }));
|
|
502
|
+
expect(JSON.parse(logSpy.mock.calls[0][0] as string).entries.map((e: { record: { id: string } }) => e.record.id))
|
|
503
|
+
.toEqual(["t2"]);
|
|
504
|
+
logSpy.mockRestore();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("two ConvergeOps sharing one environment read that ledger once", async () => {
|
|
508
|
+
discoverConvergeOpsMock.mockResolvedValue({
|
|
509
|
+
ops: [
|
|
510
|
+
{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } },
|
|
511
|
+
{ config: { name: "other-converge", searchAttributes: { Env: "staging" } } },
|
|
512
|
+
],
|
|
513
|
+
errors: [],
|
|
514
|
+
});
|
|
515
|
+
readConvergeLedgerMock.mockResolvedValue({ records: [tick()], malformed: 2 });
|
|
516
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
517
|
+
await runOperatorLog(ctx({ json: true }));
|
|
518
|
+
expect(readConvergeLedgerMock).toHaveBeenCalledTimes(1);
|
|
519
|
+
// ...and its malformed count is not double-counted.
|
|
520
|
+
expect(JSON.parse(logSpy.mock.calls[0][0] as string).malformed.converge).toBe(2);
|
|
521
|
+
logSpy.mockRestore();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("refuses an unparseable --since and a non-positive --limit without reading anything", async () => {
|
|
525
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
526
|
+
expect(await runOperatorLog(ctx({ since: "last tuesday" }))).toBe(1);
|
|
527
|
+
expect(await runOperatorLog(ctx({ limit: 0 }))).toBe(1);
|
|
528
|
+
expect(await runOperatorLog(ctx({ limit: 1.5 }))).toBe(1);
|
|
529
|
+
expect(readConvergeLedgerMock).not.toHaveBeenCalled();
|
|
530
|
+
errSpy.mockRestore();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("prints the tick id, log line and gated outcomes in the human render", async () => {
|
|
534
|
+
readConvergeLedgerMock.mockResolvedValue({
|
|
535
|
+
records: [tick({
|
|
536
|
+
id: "abcdef01-2222-3333-4444-555555555555",
|
|
537
|
+
outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate", url: "https://pr.example/1" }],
|
|
538
|
+
})],
|
|
539
|
+
malformed: 0,
|
|
540
|
+
});
|
|
541
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
542
|
+
await runOperatorLog(ctx({}));
|
|
543
|
+
const out = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
544
|
+
expect(out).toContain("[abcdef01]");
|
|
545
|
+
expect(out).toContain("converge(staging): drifted=0");
|
|
546
|
+
expect(out).toContain('gated drift-apply → fountain-apply gate "rollout-gate" https://pr.example/1');
|
|
547
|
+
logSpy.mockRestore();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("exits 0 with a warning when no ConvergeOps are discovered", async () => {
|
|
551
|
+
discoverConvergeOpsMock.mockResolvedValue({ ops: [], errors: [] });
|
|
552
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
553
|
+
expect(await runOperatorLog(ctx({}))).toBe(0);
|
|
554
|
+
errSpy.mockRestore();
|
|
555
|
+
});
|
|
556
|
+
});
|
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
} from "../../op/operator";
|
|
21
21
|
import { readLease, DEFAULT_LEASE_TTL_MS } from "../../lifecycle/lease";
|
|
22
22
|
import { readConvergeLedger, type ConvergeTickRecord } from "../../lifecycle/converge-ledger";
|
|
23
|
-
import {
|
|
23
|
+
import type { GateResolutionRecord } from "../../lifecycle/gate-ledger";
|
|
24
|
+
import { appendGateResolution, readGateResolutions, latestResolutionSince, resolveApprovalUrl, isApprovalUrl } from "../../lifecycle/gate-ledger";
|
|
24
25
|
import { pushLifecycle } from "../../lifecycle/git";
|
|
25
26
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
26
27
|
import type { CommandContext } from "../registry";
|
|
@@ -112,7 +113,8 @@ interface OpStatusLine {
|
|
|
112
113
|
op: string;
|
|
113
114
|
env: string;
|
|
114
115
|
lastTick?: ConvergeTickRecord;
|
|
115
|
-
|
|
116
|
+
/** `url` (#2028) is the gate's approval surface, when the tick that recorded it knew one — what a renderer points its approve affordance at instead of a shell command. */
|
|
117
|
+
pendingGates: { rule: string; op?: string; gate: string; url?: string }[];
|
|
116
118
|
lease?: { holder: string; expiresAt: string };
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -130,7 +132,14 @@ async function statusFor(opName: string, env: string, cwd?: string): Promise<OpS
|
|
|
130
132
|
if (outcome.action !== "gated" || !outcome.gateName || !outcome.op) continue;
|
|
131
133
|
const { records: resolutions } = await readGateResolutions(outcome.op, { cwd });
|
|
132
134
|
const resolved = latestResolutionSince(resolutions, outcome.gateName, lastTick.timestamp);
|
|
133
|
-
if (!resolved)
|
|
135
|
+
if (!resolved) {
|
|
136
|
+
pendingGates.push({
|
|
137
|
+
rule: outcome.ruleId,
|
|
138
|
+
op: outcome.op,
|
|
139
|
+
gate: outcome.gateName,
|
|
140
|
+
...(outcome.url ? { url: outcome.url } : {}),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
134
143
|
}
|
|
135
144
|
}
|
|
136
145
|
|
|
@@ -182,6 +191,7 @@ export async function runOperatorStatus(ctx: CommandContext): Promise<number> {
|
|
|
182
191
|
console.log(` pending gates:`);
|
|
183
192
|
for (const g of row.pendingGates) {
|
|
184
193
|
console.log(` - ${g.op ?? row.op} gate "${g.gate}" (rule ${g.rule}) — resolve: chant approve ${g.op ?? row.op} ${g.gate}`);
|
|
194
|
+
if (g.url) console.log(` approve at: ${g.url}`);
|
|
185
195
|
}
|
|
186
196
|
}
|
|
187
197
|
console.log("");
|
|
@@ -190,10 +200,174 @@ export async function runOperatorStatus(ctx: CommandContext): Promise<number> {
|
|
|
190
200
|
return 0;
|
|
191
201
|
}
|
|
192
202
|
|
|
203
|
+
// ── chant operator log ──────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/** One entry of the merged tick/gate timeline `chant operator log` prints. */
|
|
206
|
+
export type OperatorLogEntry =
|
|
207
|
+
| { kind: "tick"; timestamp: string; record: ConvergeTickRecord }
|
|
208
|
+
| { kind: "gate-resolution"; timestamp: string; record: GateResolutionRecord };
|
|
209
|
+
|
|
210
|
+
export interface OperatorLogResult {
|
|
211
|
+
entries: OperatorLogEntry[];
|
|
212
|
+
/**
|
|
213
|
+
* Unreadable lines behind this answer, per ledger. `readConvergeLedger` and
|
|
214
|
+
* `readGateResolutions` both skip a malformed line and count it rather than
|
|
215
|
+
* throwing, so a corrupted ledger renders a shorter timeline; without this a
|
|
216
|
+
* consumer could not tell that from a genuinely quiet environment.
|
|
217
|
+
*/
|
|
218
|
+
malformed: { converge: number; gates: number };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Gather the converge/gate history a set of ConvergeOps has recorded, merged
|
|
223
|
+
* into one timestamp-ordered timeline, oldest first.
|
|
224
|
+
*
|
|
225
|
+
* `since` (inclusive) and `limit` are applied after the merge — `limit` keeps
|
|
226
|
+
* the *newest* n entries, since that is what a log is asked for, and then
|
|
227
|
+
* prints them oldest-first like every other log.
|
|
228
|
+
*/
|
|
229
|
+
export async function collectOperatorLog(
|
|
230
|
+
ops: { name: string; env: string }[],
|
|
231
|
+
opts: { op?: string; since?: string; limit?: number; cwd?: string } = {},
|
|
232
|
+
): Promise<OperatorLogResult> {
|
|
233
|
+
const wanted = opts.op ? ops.filter((o) => o.name === opts.op) : ops;
|
|
234
|
+
const sinceMs = opts.since ? new Date(opts.since).getTime() : undefined;
|
|
235
|
+
|
|
236
|
+
const entries: OperatorLogEntry[] = [];
|
|
237
|
+
const malformed = { converge: 0, gates: 0 };
|
|
238
|
+
|
|
239
|
+
// One read per distinct environment — several ConvergeOps can share one
|
|
240
|
+
// `<env>/converge.jsonl`, and re-reading it per op would both cost more and
|
|
241
|
+
// double-count its malformed lines.
|
|
242
|
+
const envs = [...new Set(wanted.map((o) => o.env))].sort();
|
|
243
|
+
const opNames = new Set(wanted.map((o) => o.name));
|
|
244
|
+
const gateOps = new Set<string>();
|
|
245
|
+
|
|
246
|
+
for (const env of envs) {
|
|
247
|
+
const { records, malformed: bad } = await readConvergeLedger(env, { cwd: opts.cwd });
|
|
248
|
+
malformed.converge += bad;
|
|
249
|
+
for (const record of records) {
|
|
250
|
+
if (!opNames.has(record.op)) continue;
|
|
251
|
+
for (const outcome of record.outcomes) {
|
|
252
|
+
if (outcome.action === "gated" && outcome.op) gateOps.add(outcome.op);
|
|
253
|
+
}
|
|
254
|
+
entries.push({ kind: "tick", timestamp: record.timestamp, record });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const gateOp of [...gateOps].sort()) {
|
|
259
|
+
const { records, malformed: bad } = await readGateResolutions(gateOp, { cwd: opts.cwd });
|
|
260
|
+
malformed.gates += bad;
|
|
261
|
+
for (const record of records) {
|
|
262
|
+
entries.push({ kind: "gate-resolution", timestamp: record.timestamp, record });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let merged = entries.sort((a, b) => {
|
|
267
|
+
const delta = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
|
268
|
+
// A gate resolution recorded in the same instant as a tick reads after it:
|
|
269
|
+
// the tick is what made the gate pending in the first place.
|
|
270
|
+
return delta !== 0 ? delta : (a.kind === b.kind ? 0 : a.kind === "tick" ? -1 : 1);
|
|
271
|
+
});
|
|
272
|
+
if (sinceMs !== undefined) merged = merged.filter((e) => new Date(e.timestamp).getTime() >= sinceMs);
|
|
273
|
+
if (opts.limit !== undefined && merged.length > opts.limit) merged = merged.slice(-opts.limit);
|
|
274
|
+
|
|
275
|
+
return { entries: merged, malformed };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderLogEntry(entry: OperatorLogEntry): string[] {
|
|
279
|
+
if (entry.kind === "gate-resolution") {
|
|
280
|
+
const { record } = entry;
|
|
281
|
+
return [
|
|
282
|
+
`${record.timestamp} gate-resolved ${record.op}/${record.gate} by ${record.resolvedBy}` +
|
|
283
|
+
(record.url ? ` ${record.url}` : ""),
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const { record } = entry;
|
|
288
|
+
const id = record.id ? `[${record.id.slice(0, 8)}]` : "[--------]";
|
|
289
|
+
const lines = [`${record.timestamp} ${record.op}@${record.env} ${id} ${record.log}`];
|
|
290
|
+
for (const outcome of record.outcomes) {
|
|
291
|
+
if (outcome.action !== "gated" || !outcome.gateName) continue;
|
|
292
|
+
lines.push(
|
|
293
|
+
` gated ${outcome.ruleId} → ${outcome.op ?? "?"} gate "${outcome.gateName}"` +
|
|
294
|
+
(outcome.url ? ` ${outcome.url}` : ""),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
return lines;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* `chant operator log [--env <env>] [--op <name>] [--since <iso>] [--limit
|
|
302
|
+
* <n>] [--json]` (#2029) — the converge tick history and the gate resolutions
|
|
303
|
+
* against it, read from the `chant/lifecycle` orphan branch alone.
|
|
304
|
+
*
|
|
305
|
+
* `readConvergeLedger` has always returned every tick for an environment;
|
|
306
|
+
* until this its only caller was `operator status`, which threw the history
|
|
307
|
+
* away and kept `.at(-1)`. A consumer wanting more than the newest row had to
|
|
308
|
+
* `git show chant/lifecycle:<env>/converge.jsonl` and parse it — which pins it
|
|
309
|
+
* to the orphan branch's name, the `<env>/converge.jsonl` path convention, the
|
|
310
|
+
* `_gates/<op>.jsonl` convention and the on-disk encoding, none of which are
|
|
311
|
+
* promised contracts. This is the read surface that makes those private again.
|
|
312
|
+
*
|
|
313
|
+
* Read-only: no lease, no dispatch, no daemon. Same `--json` discipline as
|
|
314
|
+
* `operator status` — one JSON document on stdout, diagnostics on stderr.
|
|
315
|
+
*/
|
|
316
|
+
export async function runOperatorLog(ctx: CommandContext): Promise<number> {
|
|
317
|
+
const { ops, errors } = await discoverConvergeOps({ env: ctx.args.env });
|
|
318
|
+
for (const err of errors) console.error(formatWarning({ message: err }));
|
|
319
|
+
|
|
320
|
+
if (ops.length === 0) {
|
|
321
|
+
console.error(formatWarning({ message: "No ConvergeOp declarations found" }));
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (ctx.args.since && Number.isNaN(new Date(ctx.args.since).getTime())) {
|
|
326
|
+
console.error(formatError({
|
|
327
|
+
message: `--since must be an ISO-8601 timestamp (got "${ctx.args.since}")`,
|
|
328
|
+
hint: "e.g. --since 2026-01-01T00:00:00Z",
|
|
329
|
+
}));
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
if (ctx.args.limit !== undefined && (!Number.isInteger(ctx.args.limit) || ctx.args.limit < 1)) {
|
|
333
|
+
console.error(formatError({ message: `--limit must be a positive integer (got "${ctx.args.limit}")` }));
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const { entries, malformed } = await collectOperatorLog(
|
|
338
|
+
ops.map((d) => ({ name: d.config.name, env: d.config.searchAttributes?.Env ?? "unknown" })),
|
|
339
|
+
{ op: ctx.args.op, since: ctx.args.since, limit: ctx.args.limit },
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
if (ctx.args.json) {
|
|
343
|
+
console.log(JSON.stringify({ entries, malformed }, null, 2));
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const unreadable = malformed.converge + malformed.gates;
|
|
348
|
+
if (unreadable > 0) {
|
|
349
|
+
console.error(formatWarning({
|
|
350
|
+
message: `${unreadable} ledger line(s) were unreadable and are missing from this timeline ` +
|
|
351
|
+
`(converge: ${malformed.converge}, gates: ${malformed.gates})`,
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (entries.length === 0) {
|
|
356
|
+
console.error(formatWarning({ message: "No converge ticks recorded yet" }));
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
for (const entry of entries) {
|
|
361
|
+
for (const line of renderLogEntry(entry)) console.log(line);
|
|
362
|
+
}
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
|
|
193
366
|
// ── chant approve <op> <gate> ───────────────────────────────────────────────
|
|
194
367
|
|
|
195
368
|
/**
|
|
196
|
-
* `chant approve <op> <gate> [--actor <name>] [--note <text>]
|
|
369
|
+
* `chant approve <op> <gate> [--actor <name>] [--note <text>] [--url <url>]`
|
|
370
|
+
* — record the
|
|
197
371
|
* out-of-band resolution fact for a gate a converge tick recorded as gated
|
|
198
372
|
* (issue: "resolution is an out-of-band act that writes the counterpart
|
|
199
373
|
* fact"). Per the issue's own leaning on open question 3 ("local trust in
|
|
@@ -220,17 +394,31 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
220
394
|
|
|
221
395
|
const resolvedBy = ctx.args.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown";
|
|
222
396
|
|
|
397
|
+
// #2028: the resolution's link is typed. `--url` wins; otherwise, running
|
|
398
|
+
// inside the PR/MR job that carries the change is itself the address, the
|
|
399
|
+
// same env fallback `--actor` uses. `--note` stays free-text prose.
|
|
400
|
+
const url = ctx.args.url ?? resolveApprovalUrl();
|
|
401
|
+
if (url && !isApprovalUrl(url)) {
|
|
402
|
+
console.error(formatError({
|
|
403
|
+
message: `--url must be an absolute http/https URL (got "${url}")`,
|
|
404
|
+
hint: "Pass the PR/MR link, or omit --url and put prose in --note.",
|
|
405
|
+
}));
|
|
406
|
+
return 1;
|
|
407
|
+
}
|
|
408
|
+
|
|
223
409
|
const { record } = await appendGateResolution({
|
|
224
410
|
op: opName,
|
|
225
411
|
gate,
|
|
226
412
|
resolvedBy,
|
|
227
413
|
timestamp: new Date().toISOString(),
|
|
228
414
|
...(ctx.args.note ? { note: ctx.args.note } : {}),
|
|
415
|
+
...(url ? { url } : {}),
|
|
229
416
|
});
|
|
230
417
|
await pushLifecycle().catch(() => undefined);
|
|
231
418
|
|
|
232
419
|
console.error(formatSuccess(
|
|
233
|
-
`Gate "${gate}" on "${opName}" resolved by ${record.resolvedBy} at ${record.timestamp}
|
|
420
|
+
`Gate "${gate}" on "${opName}" resolved by ${record.resolvedBy} at ${record.timestamp}` +
|
|
421
|
+
(record.url ? ` (${record.url})` : ""),
|
|
234
422
|
));
|
|
235
423
|
console.error(formatInfo(
|
|
236
424
|
"This records the resolution as a fact; it does not itself re-run the gated dispatch — " +
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
type WorkflowHistoryRaw,
|
|
23
23
|
} from "./run-client";
|
|
24
24
|
import { generateReport, writeReport } from "./run-report";
|
|
25
|
-
import {
|
|
25
|
+
import { extractIndexedStepRecords, stepRecordEmitter, countActivities, queryGateState } from "./op-progress";
|
|
26
26
|
import { runComponents, resolveComponentTargets, findComponentGate, listComponents } from "../../components/cli-support";
|
|
27
27
|
import { renderDriverHuman, renderDriverJson } from "../../components/driver-output";
|
|
28
28
|
import { ndjsonProgressSink } from "../../components/run-progress";
|
|
@@ -1293,17 +1293,15 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
|
|
|
1293
1293
|
// `--progress-json` (chant #1676): stream one NDJSON StepRecord per line to
|
|
1294
1294
|
// stdout as each declared step settles — the durable-path counterpart to
|
|
1295
1295
|
// `chant run <name> --json`'s local-executor record shape (op-progress.ts).
|
|
1296
|
-
// Purely additive: when the flag is absent `
|
|
1297
|
-
// and every
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1296
|
+
// Purely additive: when the flag is absent `emitNewRecords` stays undefined
|
|
1297
|
+
// and every call site is a no-op, so poll-loop behavior is unchanged.
|
|
1298
|
+
//
|
|
1299
|
+
// Dedup is by declared-step identity, not by an emitted count (chant #2032):
|
|
1300
|
+
// the terminal `final: true` pass *inserts* a `"skipped"` record at each
|
|
1301
|
+
// never-reached step's declared position, so the list is not append-only and
|
|
1302
|
+
// a count watermark shifts off the end of it.
|
|
1301
1303
|
const emitProgress = ctx.args.progressJson ? ndjsonProgressSink<StepRecord>() : undefined;
|
|
1302
|
-
|
|
1303
|
-
const emitNewRecords = (records: StepRecord[]) => {
|
|
1304
|
-
for (const record of records.slice(emittedCount)) emitProgress?.(record);
|
|
1305
|
-
emittedCount = records.length;
|
|
1306
|
-
};
|
|
1304
|
+
const emitNewRecords = emitProgress ? stepRecordEmitter(emitProgress) : undefined;
|
|
1307
1305
|
|
|
1308
1306
|
let finalDesc: WorkflowExecutionDescription | undefined;
|
|
1309
1307
|
let finalHistory: WorkflowHistoryRaw | undefined;
|
|
@@ -1315,11 +1313,11 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
|
|
|
1315
1313
|
const history = await fetchNormalizedHistory(handle);
|
|
1316
1314
|
|
|
1317
1315
|
renderProgress(opName, history);
|
|
1318
|
-
|
|
1316
|
+
emitNewRecords?.(extractIndexedStepRecords(config, history));
|
|
1319
1317
|
|
|
1320
1318
|
if (TERMINAL_STATUSES.has(desc.status.name)) {
|
|
1321
1319
|
process.stderr.write("\n");
|
|
1322
|
-
|
|
1320
|
+
emitNewRecords?.(extractIndexedStepRecords(config, history, { final: true }));
|
|
1323
1321
|
finalDesc = desc;
|
|
1324
1322
|
finalHistory = history;
|
|
1325
1323
|
break;
|
package/src/cli/main.test.ts
CHANGED
|
@@ -256,6 +256,25 @@ describe("parseArgs", () => {
|
|
|
256
256
|
expect(result.json).toBe(true);
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
+
test("parses --op, --since and --limit for operator log (#2029)", () => {
|
|
260
|
+
const result = parseArgs([
|
|
261
|
+
"operator", "log", "--env", "staging", "--op", "staging-converge",
|
|
262
|
+
"--since", "2026-01-01T00:00:00Z", "--limit", "20", "--json",
|
|
263
|
+
]);
|
|
264
|
+
expect(result.command).toBe("operator");
|
|
265
|
+
expect(result.path).toBe("log");
|
|
266
|
+
expect(result.env).toBe("staging");
|
|
267
|
+
expect(result.op).toBe("staging-converge");
|
|
268
|
+
expect(result.since).toBe("2026-01-01T00:00:00Z");
|
|
269
|
+
expect(result.limit).toBe(20);
|
|
270
|
+
expect(result.json).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("parses --url for approve (#2028)", () => {
|
|
274
|
+
const result = parseArgs(["approve", "fountain-apply", "rollout-gate", "--url", "https://github.com/org/repo/pull/1"]);
|
|
275
|
+
expect(result.url).toBe("https://github.com/org/repo/pull/1");
|
|
276
|
+
});
|
|
277
|
+
|
|
259
278
|
// ── --no-release-record (#597) ───────────────────────────────────────────
|
|
260
279
|
|
|
261
280
|
test("parses --no-release-record for run --components", () => {
|