@intentius/chant 0.18.29 → 0.18.31

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.
@@ -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
+ }
@@ -62,4 +62,52 @@ describe("observeResources", () => {
62
62
  const { observations } = await observeResources("prod", [empty, noObserve], mockBuild());
63
63
  expect(observations).toEqual([]);
64
64
  });
65
+
66
+ it("with no stacks: calls describeResources exactly once with no `stack` key (unchanged single-stack path)", async () => {
67
+ const calls: Array<{ stack?: string }> = [];
68
+ const plugins = [
69
+ awsPlugin((opts) => {
70
+ calls.push(opts as { stack?: string });
71
+ return { "web-vpc": { type: "AWS::EC2::VPC", status: "CREATE_COMPLETE" } };
72
+ }),
73
+ ];
74
+ const { observations } = await observeResources("prod", plugins, mockBuild());
75
+ expect(calls).toHaveLength(1);
76
+ expect(calls[0]).not.toHaveProperty("stack");
77
+ expect(Object.keys(observations[0].resources)).toEqual(["web-vpc"]);
78
+ });
79
+
80
+ it("with stacks: [s1, s2] — calls describeResources once per stack and unions the results", async () => {
81
+ const calls: Array<string | undefined> = [];
82
+ const plugins = [
83
+ awsPlugin((opts) => {
84
+ const stack = (opts as { stack?: string }).stack;
85
+ calls.push(stack);
86
+ // Different resources per stack — the multi-stack, per-component case.
87
+ const resources: Record<string, ResourceMetadata> =
88
+ stack === "s1"
89
+ ? { "db-a": { type: "AWS::RDS::DBInstance", status: "AVAILABLE" } }
90
+ : { "db-b": { type: "AWS::RDS::DBInstance", status: "AVAILABLE" } };
91
+ return resources;
92
+ }),
93
+ ];
94
+ const { observations, errors } = await observeResources("prod", plugins, mockBuild(), { stacks: ["s1", "s2"] });
95
+ expect(calls).toEqual(["s1", "s2"]);
96
+ expect(errors).toEqual([]);
97
+ expect(observations).toHaveLength(1);
98
+ expect(Object.keys(observations[0].resources).sort()).toEqual(["db-a", "db-b"]);
99
+ });
100
+
101
+ it("with an empty stacks array — falls back to the single unstacked call", async () => {
102
+ const calls: Array<{ stack?: string }> = [];
103
+ const plugins = [
104
+ awsPlugin((opts) => {
105
+ calls.push(opts as { stack?: string });
106
+ return { "web-vpc": { type: "AWS::EC2::VPC", status: "CREATE_COMPLETE" } };
107
+ }),
108
+ ];
109
+ await observeResources("prod", plugins, mockBuild(), { stacks: [] });
110
+ expect(calls).toHaveLength(1);
111
+ expect(calls[0]).not.toHaveProperty("stack");
112
+ });
65
113
  });
@@ -27,14 +27,27 @@ export interface ObserveResult {
27
27
  * marker channel logs and returns everything (its own contract). Plugins that
28
28
  * throw are collected into `errors` and skipped — one failing lexicon never
29
29
  * sinks the whole graph.
30
+ *
31
+ * `stacks` (#57) is for a multi-stack, per-component project (e.g. loomster)
32
+ * where there is no single stack named after the environment — AWS's
33
+ * single-stack convention (`lexicons/aws/src/plugin.ts`'s `describeResources`,
34
+ * absent an explicit `stack`) queries a stack that simply doesn't exist there,
35
+ * so the single-call path always observes zero nodes. When `stacks` is
36
+ * present and non-empty, each observing plugin's `describeResources` is
37
+ * called once per stack (same `environment`/`entities`/`entityNames`, only
38
+ * `stack` varies) and the returned resource maps are unioned — a resource
39
+ * appears under whichever stack contains its logical id. When `stacks` is
40
+ * absent or empty, behavior is exactly the single call of before (no `stack`
41
+ * key at all), so a single-stack project is unaffected.
30
42
  */
31
43
  export async function observeResources(
32
44
  environment: string,
33
45
  plugins: ObservationLexicon[],
34
46
  buildResult: BuildResult,
35
- opts?: { owned?: boolean },
47
+ opts?: { owned?: boolean; stacks?: string[] },
36
48
  ): Promise<ObserveResult> {
37
49
  const owned = opts?.owned ?? true;
50
+ const stacks = opts?.stacks ?? [];
38
51
  const observations: LiveObservation[] = [];
39
52
  const warnings: string[] = [];
40
53
  const errors: string[] = [];
@@ -64,13 +77,29 @@ export async function observeResources(
64
77
  }
65
78
 
66
79
  try {
67
- const resources: Record<string, ResourceMetadata> = await plugin.describeResources({
68
- environment,
69
- buildOutput,
70
- entityNames,
71
- entities,
72
- owned,
73
- });
80
+ let resources: Record<string, ResourceMetadata>;
81
+ if (stacks.length > 0) {
82
+ resources = {};
83
+ for (const stack of stacks) {
84
+ const perStack = await plugin.describeResources({
85
+ environment,
86
+ buildOutput,
87
+ entityNames,
88
+ entities,
89
+ owned,
90
+ stack,
91
+ });
92
+ Object.assign(resources, perStack);
93
+ }
94
+ } else {
95
+ resources = await plugin.describeResources({
96
+ environment,
97
+ buildOutput,
98
+ entityNames,
99
+ entities,
100
+ owned,
101
+ });
102
+ }
74
103
  if (Object.keys(resources).length > 0) {
75
104
  observations.push({ lexicon: plugin.name, resources });
76
105
  }