@intentius/chant 0.18.28 → 0.18.30

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.
@@ -37,6 +37,7 @@ import {
37
37
  type DriverRunResult,
38
38
  } from "./driver";
39
39
  import { isLexiconPlugin, type LexiconPlugin, type ComponentPipelineOptions } from "../lexicon";
40
+ import type { RunProgressEvent } from "./run-progress";
40
41
  import { relative } from "node:path";
41
42
  import { buildCapabilityRegistry } from "./capability-plugin-loader";
42
43
  import type { CapabilityRegistry } from "./capability";
@@ -323,6 +324,18 @@ export interface RunComponentsOptions {
323
324
  * ran elsewhere. Merged with, and overridden by, outputs this run produces.
324
325
  */
325
326
  componentOutputs?: Record<string, Record<string, unknown>>;
327
+ /**
328
+ * Opt-in structured progress observer (`chant run --components <name|all>
329
+ * --progress-json`, see ./run-progress.ts). Threaded straight through to
330
+ * `runInterpretDriver` for `selector === "all"`; for a single-component
331
+ * selector (which bypasses `runInterpretDriver` — see the docstring above),
332
+ * this function emits the same `run-start`/`wave-start`/`component-start`/
333
+ * …/`run-done` envelope itself, treating the one component as a run with a
334
+ * single wave, so `--progress-json` produces a well-formed event stream
335
+ * regardless of selector. Left `undefined` by every caller that didn't pass
336
+ * `--progress-json`, in which case nothing changes.
337
+ */
338
+ onProgress?: (event: RunProgressEvent) => void;
326
339
  }
327
340
 
328
341
  /** Result of `chant run --components <name|all>`. */
@@ -449,10 +462,11 @@ export async function runComponents(
449
462
  // from a caller (a downstream CI job passing an upstream job's dumped
450
463
  // outputs via `--seed-outputs`); grows as this run's components complete.
451
464
  const seedOutputs = options.componentOutputs ?? {};
465
+ const { onProgress } = options;
452
466
 
453
467
  try {
454
468
  if (selector === "all") {
455
- const run = await runInterpretDriver(resolvedTargets, registry, { env, componentOutputs: seedOutputs });
469
+ const run = await runInterpretDriver(resolvedTargets, registry, { env, componentOutputs: seedOutputs, onProgress });
456
470
  return { success: true, run, selected };
457
471
  }
458
472
 
@@ -462,8 +476,28 @@ export async function runComponents(
462
476
  // so a single-component run still resolves their `stackOutput()`/publish
463
477
  // references. `runComponentDeploy` mutates this map with this component's
464
478
  // own outputs, which `run.componentOutputs` then exposes for `--dump-outputs`.
479
+ //
480
+ // There's no `runInterpretDriver` call here to thread progress through
481
+ // (that's the whole point of the single-component bypass), so the
482
+ // run-start/wave-start/component-start/…/run-done envelope is emitted by
483
+ // hand, treating this one component as a single-wave run — the same shape
484
+ // `run.waves`/`run.order` below already give it.
465
485
  const componentOutputs = { ...seedOutputs };
466
- const componentResult = await runComponentDeploy(resolvedTargets[0], { env, component: resolvedTargets[0].name }, registry, componentOutputs);
486
+ const componentName = resolvedTargets[0].name;
487
+ onProgress?.({ type: "run-start", waves: [selected] });
488
+ onProgress?.({ type: "wave-start", wave: 1, components: selected });
489
+ onProgress?.({ type: "component-start", wave: 1, component: componentName });
490
+ const componentResult = await runComponentDeploy(
491
+ resolvedTargets[0],
492
+ { env, component: componentName },
493
+ registry,
494
+ componentOutputs,
495
+ onProgress,
496
+ );
497
+ const status: "ok" | "failed" = componentResult.ok ? "ok" : "failed";
498
+ onProgress?.({ type: "component-done", wave: 1, component: componentName, status });
499
+ onProgress?.({ type: "wave-done", wave: 1, status });
500
+ onProgress?.({ type: "run-done", status });
467
501
  const run: DriverRunResult = {
468
502
  order: selected,
469
503
  waves: [selected],
@@ -23,6 +23,8 @@ import {
23
23
  runComponentDeploy,
24
24
  runInterpretDriver,
25
25
  type DriverComponent,
26
+ type DriverStepRecord,
27
+ type RunProgressEvent,
26
28
  } from "./driver";
27
29
  import { neo4jCluster } from "./pilots/neo4j-fanout.pilot";
28
30
  import { ordersTable } from "./pilots/dynamodb.pilot";
@@ -618,6 +620,194 @@ describe("runInterpretDriver — end to end", () => {
618
620
  });
619
621
  });
620
622
 
623
+ describe("onProgress — --progress-json event stream (M3, additive over the wave/component/phase/step loop)", () => {
624
+ /** Drop the nondeterministic `durationMs` field so a run's records can be compared for equality across two invocations. */
625
+ function stripTiming(records: DriverStepRecord[]): Omit<DriverStepRecord, "durationMs">[] {
626
+ return records.map(({ durationMs: _durationMs, ...rest }) => rest);
627
+ }
628
+
629
+ it("emits run-start -> wave-start -> component-start -> phase-start -> step(running/ok) -> phase-done -> component-done -> wave-done, once per wave, then run-done", async () => {
630
+ const registry = new CapabilityRegistry();
631
+ registry.register(fakeCapability("step-a", { run: () => ({ ok: true }) }).capability);
632
+ registry.register(fakeCapability("step-b", { run: () => ({ ok: true }) }).capability);
633
+
634
+ const a: DriverComponent = { name: "a", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "step-a" }] }] };
635
+ const b: DriverComponent = { name: "b", dependsOn: ["a"], deploy: [{ phase: "Apply", steps: [{ kind: "step-b" }] }] };
636
+
637
+ const events: RunProgressEvent[] = [];
638
+ const result = await runInterpretDriver([a, b], registry, { env: "dev", onProgress: (e) => events.push(e) });
639
+
640
+ expect(result.ok).toBe(true);
641
+ expect(events.map((e) => e.type)).toEqual([
642
+ "run-start",
643
+ "wave-start",
644
+ "component-start",
645
+ "phase-start",
646
+ "step",
647
+ "step",
648
+ "phase-done",
649
+ "component-done",
650
+ "wave-done",
651
+ "wave-start",
652
+ "component-start",
653
+ "phase-start",
654
+ "step",
655
+ "step",
656
+ "phase-done",
657
+ "component-done",
658
+ "wave-done",
659
+ "run-done",
660
+ ]);
661
+
662
+ // Wave 1: component "a".
663
+ expect(events[0]).toEqual({ type: "run-start", waves: [["a"], ["b"]] });
664
+ expect(events[1]).toEqual({ type: "wave-start", wave: 1, components: ["a"] });
665
+ expect(events[2]).toEqual({ type: "component-start", wave: 1, component: "a" });
666
+ expect(events[3]).toEqual({ type: "phase-start", component: "a", phase: "Apply" });
667
+ expect(events[4]).toEqual({ type: "step", component: "a", phase: "Apply", step: "step-a", status: "running" });
668
+ expect(events[5]).toEqual({ type: "step", component: "a", phase: "Apply", step: "step-a", status: "ok" });
669
+ expect(events[6]).toEqual({ type: "phase-done", component: "a", phase: "Apply", status: "ok" });
670
+ expect(events[7]).toEqual({ type: "component-done", wave: 1, component: "a", status: "ok" });
671
+ expect(events[8]).toEqual({ type: "wave-done", wave: 1, status: "ok" });
672
+
673
+ // Wave 2: component "b" (1-based wave numbering).
674
+ expect(events[9]).toEqual({ type: "wave-start", wave: 2, components: ["b"] });
675
+ expect(events[10]).toEqual({ type: "component-start", wave: 2, component: "b" });
676
+ expect(events[15]).toEqual({ type: "component-done", wave: 2, component: "b", status: "ok" });
677
+ expect(events[16]).toEqual({ type: "wave-done", wave: 2, status: "ok" });
678
+
679
+ expect(events[17]).toEqual({ type: "run-done", status: "ok" });
680
+ });
681
+
682
+ it("a failing step yields step:failed (with error), phase-done:failed, component-done:failed, wave-done:failed, run-done:failed — and a later wave never starts", async () => {
683
+ const registry = new CapabilityRegistry();
684
+ registry.register(fakeCapability("bad-step", { failRun: true }).capability);
685
+ registry.register(fakeCapability("step-b", { run: () => ({ ok: true }) }).capability);
686
+
687
+ const a: DriverComponent = { name: "a", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "bad-step" }] }] };
688
+ const b: DriverComponent = { name: "b", dependsOn: ["a"], deploy: [{ phase: "Apply", steps: [{ kind: "step-b" }] }] };
689
+
690
+ const events: RunProgressEvent[] = [];
691
+ await expect(
692
+ runInterpretDriver([a, b], registry, { env: "dev", onProgress: (e) => events.push(e) }),
693
+ ).rejects.toThrow(DriverRunFailure);
694
+
695
+ expect(events.map((e) => e.type)).toEqual([
696
+ "run-start",
697
+ "wave-start",
698
+ "component-start",
699
+ "phase-start",
700
+ "step",
701
+ "step",
702
+ "phase-done",
703
+ "component-done",
704
+ "wave-done",
705
+ "run-done",
706
+ ]);
707
+ // Only wave 1 ever starts — the driver stops at the first failed
708
+ // component, so wave 2 (containing "b") never runs.
709
+ expect(events.filter((e) => e.type === "wave-start")).toHaveLength(1);
710
+ expect(events.filter((e) => e.type === "component-start")).toEqual([{ type: "component-start", wave: 1, component: "a" }]);
711
+
712
+ expect(events[4]).toEqual({ type: "step", component: "a", phase: "Apply", step: "bad-step", status: "running" });
713
+ expect(events[5]).toEqual({
714
+ type: "step",
715
+ component: "a",
716
+ phase: "Apply",
717
+ step: "bad-step",
718
+ status: "failed",
719
+ error: "bad-step failed",
720
+ });
721
+ expect(events[6]).toEqual({ type: "phase-done", component: "a", phase: "Apply", status: "failed" });
722
+ expect(events[7]).toEqual({ type: "component-done", wave: 1, component: "a", status: "failed" });
723
+ expect(events[8]).toEqual({ type: "wave-done", wave: 1, status: "failed" });
724
+ expect(events[9]).toEqual({ type: "run-done", status: "failed" });
725
+ });
726
+
727
+ it("produces the identical DriverRunResult (minus timing) whether or not onProgress is passed", async () => {
728
+ function buildRegistry(): CapabilityRegistry {
729
+ const registry = new CapabilityRegistry();
730
+ registry.register(fakeCapability("step-a", { run: () => ({ ok: true }) }).capability);
731
+ registry.register(fakeCapability("step-b", { run: () => ({ ok: true }) }).capability);
732
+ return registry;
733
+ }
734
+ const a: DriverComponent = { name: "a", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "step-a" }] }] };
735
+ const b: DriverComponent = { name: "b", dependsOn: ["a"], deploy: [{ phase: "Apply", steps: [{ kind: "step-b" }] }] };
736
+
737
+ const withoutProgress = await runInterpretDriver([a, b], buildRegistry(), { env: "dev" });
738
+ const events: RunProgressEvent[] = [];
739
+ const withProgress = await runInterpretDriver([a, b], buildRegistry(), {
740
+ env: "dev",
741
+ onProgress: (e) => events.push(e),
742
+ });
743
+
744
+ // onProgress was actually exercised — otherwise this comparison would be vacuous.
745
+ expect(events.length).toBeGreaterThan(0);
746
+
747
+ const normalize = (r: typeof withoutProgress) => ({
748
+ ...r,
749
+ results: r.results.map((cr) => ({ ...cr, records: stripTiming(cr.records) })),
750
+ });
751
+ expect(normalize(withProgress)).toEqual(normalize(withoutProgress));
752
+ });
753
+
754
+ it("runComponentDeploy behaves identically with onProgress omitted (undefined-safe, no-op)", async () => {
755
+ const registry = new CapabilityRegistry();
756
+ registry.register(fakeCapability("step-a", { run: () => ({ ok: true }) }).capability);
757
+ const component: DriverComponent = { name: "c", deploy: [{ phase: "Apply", steps: [{ kind: "step-a" }] }] };
758
+
759
+ const result = await runComponentDeploy(component, { env: "dev", component: "c" }, registry, {});
760
+
761
+ expect(result.ok).toBe(true);
762
+ expect(stripTiming(result.records)).toEqual([
763
+ { component: "c", phase: "Apply", kind: "step-a", status: "ok", output: { ok: true } },
764
+ ]);
765
+ });
766
+
767
+ it("onFailure saga rollback: rollback-unwind steps are not reported as `step` progress events (only the forward failing step and the component's own authored rollback phase are)", async () => {
768
+ const registry = new CapabilityRegistry();
769
+ registry.register(
770
+ fakeCapability("provision", { run: () => ({ id: "res-1" }), rollback: () => {} }).capability,
771
+ );
772
+ registry.register(fakeCapability("apply-final", { failRun: true }).capability);
773
+ registry.register(fakeCapability("compensate", { run: () => ({ restored: true }) }).capability);
774
+
775
+ const component: DriverComponent = {
776
+ name: "c",
777
+ deploy: [
778
+ { phase: "Provision", steps: [{ kind: "provision" }] },
779
+ { phase: "Apply", steps: [{ kind: "apply-final" }] },
780
+ ],
781
+ rollback: [{ phase: "Rollback", steps: [{ kind: "compensate" }] }],
782
+ };
783
+
784
+ const events: RunProgressEvent[] = [];
785
+ const result = await runComponentDeploy(component, { env: "dev", component: "c" }, registry, {}, (e) =>
786
+ events.push(e),
787
+ );
788
+
789
+ expect(result.ok).toBe(false);
790
+ const stepEvents = events.filter((e): e is Extract<RunProgressEvent, { type: "step" }> => e.type === "step");
791
+ // "provision" (forward, ok), "apply-final" (forward, failed), "compensate"
792
+ // (the component's own authored rollback phase) — never the saga unwind's
793
+ // reverse-order capability.rollback() call for "provision", which the
794
+ // driver still performs but doesn't surface as a `step` event.
795
+ expect(stepEvents.map((e) => `${e.step}:${e.status}`)).toEqual([
796
+ "provision:running",
797
+ "provision:ok",
798
+ "apply-final:running",
799
+ "apply-final:failed",
800
+ "compensate:running",
801
+ "compensate:ok",
802
+ ]);
803
+ expect(events.filter((e) => e.type === "phase-start").map((e) => (e as { phase: string }).phase)).toEqual([
804
+ "Provision",
805
+ "Apply",
806
+ "Rollback",
807
+ ]);
808
+ });
809
+ });
810
+
621
811
  describe("capabilities are consumed exactly as the registry provides them", () => {
622
812
  // No starter verb is a stub any more, but the stub *mechanism*
623
813
  // (./verbs/stub.ts) stays for third-party plugins / future verbs. A stub
@@ -43,6 +43,9 @@
43
43
 
44
44
  import { topoSort } from "../codegen/topo-sort";
45
45
  import type { CapabilityRegistry, DeployContext } from "./capability";
46
+ import type { RunProgressEvent } from "./run-progress";
47
+
48
+ export type { RunProgressEvent } from "./run-progress";
46
49
 
47
50
  // ── Component-shaped input (mirrors component.schema.json / ./component.ts) ──
48
51
 
@@ -375,6 +378,15 @@ async function runCapabilityStep(
375
378
  * inheriting `parallel` from its own definition, not its parent's). Steps run
376
379
  * sequentially unless `phase.parallel` is set, in which case they run via
377
380
  * `Promise.all`, matching ../op/local-executor.ts's phase semantics.
381
+ *
382
+ * `onProgress`, when supplied, is called with a `phase-start` event before any
383
+ * step runs, a `step` event around each capability invocation (`"running"`
384
+ * then `"ok"`/`"failed"`), and a `phase-done` event once the phase settles —
385
+ * purely additive observation, never consulted for control flow. Recursion
386
+ * into a nested fan-out phase passes the same callback through, so a nested
387
+ * phase's events use its own name. Left `undefined` by every caller that
388
+ * doesn't opt into `--progress-json` (see ./run-progress.ts), in which case
389
+ * every `onProgress?.(...)` call below is a no-op and behavior is unchanged.
378
390
  */
379
391
  async function runPhase(
380
392
  phaseDef: DriverPhase,
@@ -382,6 +394,7 @@ async function runPhase(
382
394
  registry: CapabilityRegistry,
383
395
  phaseOutputs: Record<string, Record<string, unknown>>,
384
396
  componentOutputs: Record<string, Record<string, unknown>>,
397
+ onProgress?: (event: RunProgressEvent) => void,
385
398
  ): Promise<{ records: DriverStepRecord[]; executed: ExecutedStep[] }> {
386
399
  const gate = phaseDef.steps.find(isGateStep);
387
400
  if (gate) throw new DriverGateUnsupportedError(ctx.component, gate.signalName);
@@ -393,13 +406,14 @@ async function runPhase(
393
406
  ): Promise<{ records: DriverStepRecord[]; executed: ExecutedStep[]; failed: boolean }> => {
394
407
  if (isPhaseStep(entry)) {
395
408
  try {
396
- const nested = await runPhase(entry, ctx, registry, phaseOutputs, componentOutputs);
409
+ const nested = await runPhase(entry, ctx, registry, phaseOutputs, componentOutputs, onProgress);
397
410
  return { ...nested, failed: false };
398
411
  } catch (err) {
399
412
  if (err instanceof StepFailure) return { records: err.records, executed: err.executed, failed: true };
400
413
  throw err;
401
414
  }
402
415
  }
416
+ onProgress?.({ type: "step", component: ctx.component, phase: phaseDef.phase, step: entry.kind, status: "running" });
403
417
  const { record, resolvedInput, output } = await runCapabilityStep(
404
418
  entry,
405
419
  phaseDef.phase,
@@ -408,6 +422,14 @@ async function runPhase(
408
422
  phaseOutputs,
409
423
  componentOutputs,
410
424
  );
425
+ onProgress?.({
426
+ type: "step",
427
+ component: ctx.component,
428
+ phase: phaseDef.phase,
429
+ step: entry.kind,
430
+ status: record.status === "ok" ? "ok" : "failed",
431
+ ...(record.error !== undefined ? { error: record.error } : {}),
432
+ });
411
433
  if (record.status === "ok") {
412
434
  phaseOutputs[phaseDef.phase] = { ...(phaseOutputs[phaseDef.phase] ?? {}), ...(output as object) };
413
435
  }
@@ -418,35 +440,49 @@ async function runPhase(
418
440
  };
419
441
  };
420
442
 
421
- if (phaseDef.parallel) {
422
- const results = await Promise.all(entries.map(runEntry));
423
- const records = results.flatMap((r) => r.records);
424
- const executed = results.flatMap((r) => r.executed);
425
- if (results.some((r) => r.failed)) throw new StepFailure(records, executed);
426
- return { records, executed };
427
- }
443
+ const runEntries = async (): Promise<{ records: DriverStepRecord[]; executed: ExecutedStep[] }> => {
444
+ if (phaseDef.parallel) {
445
+ const results = await Promise.all(entries.map(runEntry));
446
+ const records = results.flatMap((r) => r.records);
447
+ const executed = results.flatMap((r) => r.executed);
448
+ if (results.some((r) => r.failed)) throw new StepFailure(records, executed);
449
+ return { records, executed };
450
+ }
428
451
 
429
- const records: DriverStepRecord[] = [];
430
- const executed: ExecutedStep[] = [];
431
- for (let i = 0; i < entries.length; i++) {
432
- const result = await runEntry(entries[i]);
433
- records.push(...result.records);
434
- executed.push(...result.executed);
435
- if (result.failed) {
436
- for (const skipped of entries.slice(i + 1)) {
437
- const skippedKind = isPhaseStep(skipped) ? skipped.phase : (skipped as DriverStep).kind;
438
- records.push({
439
- component: ctx.component,
440
- phase: phaseDef.phase,
441
- kind: skippedKind,
442
- status: "skipped",
443
- durationMs: 0,
444
- });
452
+ const records: DriverStepRecord[] = [];
453
+ const executed: ExecutedStep[] = [];
454
+ for (let i = 0; i < entries.length; i++) {
455
+ const result = await runEntry(entries[i]);
456
+ records.push(...result.records);
457
+ executed.push(...result.executed);
458
+ if (result.failed) {
459
+ for (const skipped of entries.slice(i + 1)) {
460
+ const skippedKind = isPhaseStep(skipped) ? skipped.phase : (skipped as DriverStep).kind;
461
+ records.push({
462
+ component: ctx.component,
463
+ phase: phaseDef.phase,
464
+ kind: skippedKind,
465
+ status: "skipped",
466
+ durationMs: 0,
467
+ });
468
+ }
469
+ throw new StepFailure(records, executed);
445
470
  }
446
- throw new StepFailure(records, executed);
447
471
  }
472
+ return { records, executed };
473
+ };
474
+
475
+ onProgress?.({ type: "phase-start", component: ctx.component, phase: phaseDef.phase });
476
+ try {
477
+ const result = await runEntries();
478
+ onProgress?.({ type: "phase-done", component: ctx.component, phase: phaseDef.phase, status: "ok" });
479
+ return result;
480
+ } catch (err) {
481
+ if (err instanceof StepFailure) {
482
+ onProgress?.({ type: "phase-done", component: ctx.component, phase: phaseDef.phase, status: "failed" });
483
+ }
484
+ throw err;
448
485
  }
449
- return { records, executed };
450
486
  }
451
487
 
452
488
  /**
@@ -517,12 +553,22 @@ async function rollbackExecuted(
517
553
  * handling. Cross-component artifact outputs this component published (if
518
554
  * any) are recorded into `componentOutputs` under its own name so downstream
519
555
  * components can reference `@<name>.publish.*`.
556
+ *
557
+ * `onProgress`, when supplied, is forwarded to every `runPhase` call (both the
558
+ * forward `deploy` phases and, on failure, the component's own authored
559
+ * `rollback` phases) so a `--progress-json` consumer sees `phase-start`/
560
+ * `step`/`phase-done` events for whichever phases actually ran. The saga
561
+ * unwind step-by-step compensation (`rollbackExecuted` below) is not part of
562
+ * the `RunProgressEvent` contract and stays silent — it isn't a `deploy`
563
+ * phase, and its record statuses (`rolled-back`/`rollback-opted-out`) don't
564
+ * map onto the `step` event's `running`/`ok`/`failed` shape.
520
565
  */
521
566
  export async function runComponentDeploy(
522
567
  component: DriverComponent,
523
568
  ctx: DeployContext,
524
569
  registry: CapabilityRegistry,
525
570
  componentOutputs: Record<string, Record<string, unknown>>,
571
+ onProgress?: (event: RunProgressEvent) => void,
526
572
  ): Promise<DriverComponentResult> {
527
573
  const phaseOutputs: Record<string, Record<string, unknown>> = {};
528
574
  const records: DriverStepRecord[] = [];
@@ -530,7 +576,7 @@ export async function runComponentDeploy(
530
576
 
531
577
  try {
532
578
  for (const phaseDef of component.deploy) {
533
- const result = await runPhase(phaseDef, ctx, registry, phaseOutputs, componentOutputs);
579
+ const result = await runPhase(phaseDef, ctx, registry, phaseOutputs, componentOutputs, onProgress);
534
580
  records.push(...result.records);
535
581
  allExecuted.push(...result.executed);
536
582
  }
@@ -546,7 +592,7 @@ export async function runComponentDeploy(
546
592
 
547
593
  for (const phaseDef of [...(component.rollback ?? [])].reverse()) {
548
594
  try {
549
- const result = await runPhase(phaseDef, ctx, registry, phaseOutputs, componentOutputs);
595
+ const result = await runPhase(phaseDef, ctx, registry, phaseOutputs, componentOutputs, onProgress);
550
596
  records.push(...result.records);
551
597
  } catch (compErr) {
552
598
  if (compErr instanceof StepFailure) records.push(...compErr.records);
@@ -635,6 +681,16 @@ export interface InterpretRunOptions {
635
681
  vars?: Record<string, unknown>;
636
682
  /** Pre-seeded cross-component/cross-stack outputs (e.g. from a prior run, or a caller resolving `stackOutput` externally). Merged with outputs this run produces. */
637
683
  componentOutputs?: Record<string, Record<string, unknown>>;
684
+ /**
685
+ * Opt-in structured progress observer (`chant run --components all
686
+ * --progress-json`, see ./run-progress.ts). Called with `run-start`/
687
+ * `wave-start`/`component-start`/…/`run-done` events as the run executes;
688
+ * never consulted for control flow, so leaving it `undefined` (the default
689
+ * for every caller that didn't pass `--progress-json`) makes every
690
+ * `onProgress?.(...)` call below a no-op and this function's behavior is
691
+ * byte-for-byte the same as before this option existed.
692
+ */
693
+ onProgress?: (event: RunProgressEvent) => void;
638
694
  }
639
695
 
640
696
  /**
@@ -659,24 +715,34 @@ export async function runInterpretDriver(
659
715
  const { order, waves } = resolveComponentGraph(components);
660
716
  const byName = new Map(components.map((c) => [c.name, c]));
661
717
  const componentOutputs: Record<string, Record<string, unknown>> = { ...(options.componentOutputs ?? {}) };
718
+ const { onProgress } = options;
719
+
720
+ onProgress?.({ type: "run-start", waves });
662
721
 
663
722
  const results: DriverComponentResult[] = [];
664
723
  let failedComponent: string | undefined;
665
724
 
666
- waveLoop: for (const wave of waves) {
725
+ waveLoop: for (const [waveIndex, wave] of waves.entries()) {
726
+ const waveNum = waveIndex + 1;
727
+ onProgress?.({ type: "wave-start", wave: waveNum, components: wave });
667
728
  const waveComponents = wave.map((name) => byName.get(name)!);
668
729
  const waveResults = await Promise.all(
669
- waveComponents.map((component) =>
670
- runComponentDeploy(
730
+ waveComponents.map(async (component) => {
731
+ onProgress?.({ type: "component-start", wave: waveNum, component: component.name });
732
+ const result = await runComponentDeploy(
671
733
  component,
672
734
  { env: options.env, component: component.name, vars: options.vars },
673
735
  registry,
674
736
  componentOutputs,
675
- ),
676
- ),
737
+ onProgress,
738
+ );
739
+ onProgress?.({ type: "component-done", wave: waveNum, component: component.name, status: result.ok ? "ok" : "failed" });
740
+ return result;
741
+ }),
677
742
  );
678
743
  results.push(...waveResults);
679
744
  const failed = waveResults.find((r) => !r.ok);
745
+ onProgress?.({ type: "wave-done", wave: waveNum, status: failed ? "failed" : "ok" });
680
746
  if (failed) {
681
747
  failedComponent = failed.component;
682
748
  break waveLoop;
@@ -684,6 +750,7 @@ export async function runInterpretDriver(
684
750
  }
685
751
 
686
752
  const ok = failedComponent === undefined;
753
+ onProgress?.({ type: "run-done", status: ok ? "ok" : "failed" });
687
754
  const result: DriverRunResult = { order, waves, results, ok, failedComponent, componentOutputs };
688
755
  if (!ok) throw new DriverRunFailure(result);
689
756
  return result;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Structured progress events for `chant run --components <sel> --progress-json`
3
+ * (behold roadmap M3: a consumer that renders live wave/phase/step progress
4
+ * instead of tailing raw logs).
5
+ *
6
+ * This is purely additive observation over the interpret driver's existing
7
+ * wave -> component -> phase -> step execution loop (./driver.ts): an
8
+ * optional `onProgress` callback, threaded through
9
+ * `runInterpretDriver`/`runComponentDeploy`/`runPhase` (and the CLI's
10
+ * single-component path, ./cli-support.ts's `runComponents`), that never
11
+ * changes run ordering, gating, `onFailure`, rollback, or exit codes — it
12
+ * only reports what already happened, as it happens. See ./driver.ts's
13
+ * module doc for what actually executes; streaming progress on the *durable*
14
+ * (Temporal) path is a separate, later concern (Temporal already exposes
15
+ * durable run state via `chant run status`/`log`).
16
+ *
17
+ * `RunProgressEvent` is a discriminated union on `type`, one JSON object per
18
+ * NDJSON line (see `ndjsonProgressSink` below and
19
+ * ../cli/handlers/run.ts's `--progress-json` wiring).
20
+ */
21
+
22
+ /** Terminal status for a wave/component/phase/run — mirrors DriverStepRecord's ok/fail split, collapsed to the two outcomes a consumer renders progress against. */
23
+ export type RunProgressStatus = "ok" | "failed";
24
+
25
+ /** The run is about to start. `waves` are the parallel-safe waves the run will attempt, in order (see resolveComponentGraph) — 1-based wave numbers in every other event index into this array. */
26
+ export interface RunStartEvent {
27
+ type: "run-start";
28
+ waves: string[][];
29
+ }
30
+
31
+ /** A wave is about to start; its components may run concurrently (independent components share a wave). */
32
+ export interface WaveStartEvent {
33
+ type: "wave-start";
34
+ /** 1-based wave number. */
35
+ wave: number;
36
+ components: string[];
37
+ }
38
+
39
+ /** A component within a wave is about to start its `deploy` composition. */
40
+ export interface ComponentStartEvent {
41
+ type: "component-start";
42
+ wave: number;
43
+ component: string;
44
+ }
45
+
46
+ /** A named phase of a component's composition is about to run its steps. Emitted for nested (fan-out) phases too, keyed by the nested phase's own name. */
47
+ export interface PhaseStartEvent {
48
+ type: "phase-start";
49
+ component: string;
50
+ phase: string;
51
+ }
52
+
53
+ /** A single capability step within a phase — one event when it starts, one when it settles. */
54
+ export interface StepEvent {
55
+ type: "step";
56
+ component: string;
57
+ phase: string;
58
+ step: string;
59
+ status: "running" | "ok" | "failed";
60
+ /** Present only alongside `status: "failed"`, when the capability threw. */
61
+ error?: string;
62
+ }
63
+
64
+ /** A phase finished — `ok` if every step in it succeeded, `failed` if any step failed (the phase's remaining steps were skipped, per the driver's fail-fast-within-a-phase semantics). */
65
+ export interface PhaseDoneEvent {
66
+ type: "phase-done";
67
+ component: string;
68
+ phase: string;
69
+ status: RunProgressStatus;
70
+ }
71
+
72
+ /** A component's `deploy` composition finished (after any saga rollback + component-level `rollback` phases the driver ran on failure). */
73
+ export interface ComponentDoneEvent {
74
+ type: "component-done";
75
+ wave: number;
76
+ component: string;
77
+ status: RunProgressStatus;
78
+ }
79
+
80
+ /** A wave finished — `failed` if any component in it failed, which also stops the run before any later wave starts. */
81
+ export interface WaveDoneEvent {
82
+ type: "wave-done";
83
+ wave: number;
84
+ status: RunProgressStatus;
85
+ }
86
+
87
+ /** The run finished. Mirrors the driver's own terminal `DriverRunResult.ok` / exit code. */
88
+ export interface RunDoneEvent {
89
+ type: "run-done";
90
+ status: RunProgressStatus;
91
+ }
92
+
93
+ export type RunProgressEvent =
94
+ | RunStartEvent
95
+ | WaveStartEvent
96
+ | ComponentStartEvent
97
+ | PhaseStartEvent
98
+ | StepEvent
99
+ | PhaseDoneEvent
100
+ | ComponentDoneEvent
101
+ | WaveDoneEvent
102
+ | RunDoneEvent;
103
+
104
+ /** A sink that receives progress events as they occur. The driver only ever calls this — it never writes to a stream directly, so it stays testable without stdout. */
105
+ export type RunProgressSink = (event: RunProgressEvent) => void;
106
+
107
+ /**
108
+ * Build a sink that writes `JSON.stringify(event) + "\n"` to `write` (default:
109
+ * `process.stdout.write`), one line per event, as they happen — the
110
+ * `--progress-json` CLI wiring's sink (../cli/handlers/run.ts). Kept separate
111
+ * from `driver-output.ts`'s end-of-run renderers: this emits *during* the
112
+ * run, one line at a time; `renderDriverJson`/`renderDriverHuman` render the
113
+ * completed `DriverRunResult` once, after the run finishes.
114
+ */
115
+ export function ndjsonProgressSink(write: (chunk: string) => void = (s) => void process.stdout.write(s)): RunProgressSink {
116
+ return (event) => write(JSON.stringify(event) + "\n");
117
+ }
@@ -135,6 +135,29 @@ describe("status", () => {
135
135
  expect(rows[0].reconciliation).toBe("drifted");
136
136
  });
137
137
 
138
+ test("surfaces machine-readable live + stack status when observed (#57 hardening)", () => {
139
+ const liveEvidence = new Map<string, LiveComponentEvidence>([
140
+ ["search-service", { live: true, ownership: "owned", stack: { name: "app-prod-search", status: "CREATE_COMPLETE", healthy: true } }],
141
+ ]);
142
+ const rows = reconcileStatus("prod", [record()], { liveEvidence });
143
+ expect(rows[0].live).toBe(true);
144
+ expect(rows[0].stack).toEqual({ name: "app-prod-search", status: "CREATE_COMPLETE", healthy: true });
145
+ });
146
+
147
+ test("live is false (never undefined) under --live when a component's stack is absent", () => {
148
+ const liveEvidence = new Map<string, LiveComponentEvidence>([
149
+ ["search-service", { live: false }],
150
+ ]);
151
+ const rows = reconcileStatus("prod", [record()], { liveEvidence });
152
+ expect(rows[0].live).toBe(false);
153
+ });
154
+
155
+ test("live is absent (not queried) when no liveEvidence is passed", () => {
156
+ const rows = reconcileStatus("prod", [record()]);
157
+ expect(rows[0].live).toBeUndefined();
158
+ expect(rows[0].stack).toBeUndefined();
159
+ });
160
+
138
161
  test("recorded but nothing live -> stale", () => {
139
162
  const liveEvidence = new Map<string, LiveComponentEvidence>();
140
163
  const rows = reconcileStatus("prod", [record()], { liveEvidence });