@intentius/chant 0.1.6 → 0.1.8

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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/build.test.ts +58 -5
  3. package/src/cli/commands/build.ts +24 -3
  4. package/src/cli/handlers/graph.test.ts +91 -0
  5. package/src/cli/handlers/graph.ts +23 -0
  6. package/src/cli/handlers/run-client.ts +134 -0
  7. package/src/cli/handlers/run-report.ts +160 -0
  8. package/src/cli/handlers/run.test.ts +448 -0
  9. package/src/cli/handlers/run.ts +453 -0
  10. package/src/cli/handlers/state.test.ts +409 -0
  11. package/src/cli/handlers/state.ts +232 -10
  12. package/src/cli/main.test.ts +65 -0
  13. package/src/cli/main.ts +32 -18
  14. package/src/cli/mcp/op-tools.ts +204 -0
  15. package/src/cli/mcp/resource-handlers.ts +69 -50
  16. package/src/cli/mcp/resources/context.ts +27 -0
  17. package/src/cli/mcp/server.test.ts +176 -3
  18. package/src/cli/mcp/server.ts +7 -3
  19. package/src/cli/mcp/state-tools.ts +0 -51
  20. package/src/cli/mcp/tools/search.ts +6 -1
  21. package/src/cli/registry.ts +3 -0
  22. package/src/composite.ts +10 -5
  23. package/src/index.ts +1 -2
  24. package/src/lexicon-plugin-helpers.ts +13 -5
  25. package/src/lexicon.ts +57 -1
  26. package/src/lint/config.test.ts +21 -0
  27. package/src/lint/config.ts +19 -3
  28. package/src/op/discover.test.ts +43 -0
  29. package/src/op/discover.ts +89 -0
  30. package/src/op/index.ts +3 -1
  31. package/src/op/types.ts +13 -6
  32. package/src/state/digest.test.ts +117 -0
  33. package/src/state/git.test.ts +191 -0
  34. package/src/state/git.ts +63 -11
  35. package/src/state/live-diff.test.ts +184 -0
  36. package/src/state/live-diff.ts +215 -0
  37. package/src/state/snapshot.test.ts +171 -0
  38. package/src/state/snapshot.ts +39 -19
  39. package/src/state/types.ts +4 -2
  40. package/src/cli/handlers/spell.ts +0 -396
  41. package/src/spell/discovery.ts +0 -183
  42. package/src/spell/index.ts +0 -3
  43. package/src/spell/prompt.ts +0 -133
  44. package/src/spell/types.ts +0 -89
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -132,18 +132,71 @@ export const testEntity = {
132
132
  expect(result.fileCount).toBeDefined();
133
133
  });
134
134
 
135
- test("handles invalid output path", async () => {
135
+ test("creates parent directories for the primary output path (#38)", async () => {
136
+ // Write into a nested temp path whose parent dirs don't yet exist —
137
+ // chant build should mkdir -p the parents rather than fail with ENOENT.
138
+ const nestedOutput = join(testDir, "deep", "nested", "out", "file.json");
139
+
136
140
  const options: BuildOptions = {
137
141
  path: testDir,
138
- output: "/nonexistent/directory/file.json",
142
+ output: nestedOutput,
139
143
  format: "json",
140
144
  serializers: [mockSerializer],
141
145
  };
142
146
 
143
147
  const result = await buildCommand(options);
144
148
 
145
- // Should report error about output file
146
- expect(result.errors.length).toBeGreaterThan(0);
147
- expect(result.errors.some((e) => e.includes("output"))).toBe(true);
149
+ expect(result.errors).toEqual([]);
150
+ expect(existsSync(nestedOutput)).toBe(true);
151
+ });
152
+
153
+ test("creates parent directories for nested additional-files (#38)", async () => {
154
+ // Simulate a multi-file serializer (like the Op codegen) that emits
155
+ // additional files under nested subpaths.
156
+ const multiFileSerializer: Serializer = {
157
+ name: "multi",
158
+ rulePrefix: "MULTI",
159
+ serialize: () => ({
160
+ primary: "{}",
161
+ files: {
162
+ "ops/alb-deploy/workflow.ts": "// workflow\n",
163
+ "ops/alb-deploy/activities.ts": "// activities\n",
164
+ "ops/alb-deploy/worker.ts": "// worker\n",
165
+ },
166
+ }),
167
+ };
168
+
169
+ // Need at least one entity in the "multi" lexicon so the build pipeline
170
+ // invokes the serializer and the additional files surface.
171
+ await writeFile(
172
+ join(testDir, "infra.ts"),
173
+ [
174
+ `export const x = {`,
175
+ ` [Symbol.for("chant.declarable")]: true,`,
176
+ ` entityType: "X",`,
177
+ ` lexicon: "multi",`,
178
+ ` kind: "resource",`,
179
+ ` props: {},`,
180
+ ` attributes: {},`,
181
+ `};`,
182
+ ].join("\n"),
183
+ );
184
+
185
+ const outputPath = join(testDir, "dist", "manifest.json");
186
+
187
+ const options: BuildOptions = {
188
+ path: testDir,
189
+ output: outputPath,
190
+ format: "json",
191
+ serializers: [multiFileSerializer],
192
+ };
193
+
194
+ const result = await buildCommand(options);
195
+
196
+ expect(result.errors).toEqual([]);
197
+ expect(existsSync(outputPath)).toBe(true);
198
+ expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "workflow.ts"))).toBe(true);
199
+ expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "activities.ts"))).toBe(true);
200
+ expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "worker.ts"))).toBe(true);
148
201
  });
149
202
  });
@@ -5,7 +5,7 @@ import { runPostSynthChecks } from "../../lint/post-synth";
5
5
  import type { PostSynthCheck } from "../../lint/post-synth";
6
6
  import { sortedJsonReplacer } from "../../utils";
7
7
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
8
- import { writeFileSync } from "fs";
8
+ import { writeFileSync, mkdirSync } from "fs";
9
9
  import { resolve, dirname, join } from "path";
10
10
  import { watchDirectory, formatTimestamp, formatChangedFiles } from "../watch";
11
11
 
@@ -101,6 +101,23 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
101
101
  }
102
102
  }
103
103
 
104
+ // Empty-output guard: source files were discovered but no lexicon produced
105
+ // any output. Almost always indicates broken imports resolving to undefined
106
+ // (e.g. missing root re-exports from a lexicon) or modules that exported no
107
+ // Declarables. Without this guard, chant writes "{}" and exits 0.
108
+ if (
109
+ result.sourceFileCount > 0 &&
110
+ result.outputs.size === 0 &&
111
+ result.errors.length === 0 &&
112
+ errors.length === 0
113
+ ) {
114
+ errors.push(
115
+ formatError({
116
+ message: `Discovered ${result.sourceFileCount} source file(s) but produced no output. Likely causes: imports resolving to undefined (missing exports from a lexicon root), or no Declarables/Composites exported. Check that imported names exist in the target package.`,
117
+ }),
118
+ );
119
+ }
120
+
104
121
  // Handle output
105
122
  if (result.errors.length === 0 && errors.length === 0) {
106
123
  // Extract primary content and collect additional files from SerializerResult
@@ -171,9 +188,11 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
171
188
  }
172
189
 
173
190
  if (options.output) {
174
- // Write to file
191
+ // Write to file — ensure parent directories exist for both the primary
192
+ // output path and any nested additional-file paths (e.g. ops/<name>/...).
175
193
  try {
176
194
  const outputPath = resolve(options.output);
195
+ mkdirSync(dirname(outputPath), { recursive: true });
177
196
  writeFileSync(outputPath, output);
178
197
 
179
198
  // Write additional files (e.g. nested stack templates) alongside the primary output
@@ -191,7 +210,9 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
191
210
  } catch {
192
211
  // If not JSON, write as-is
193
212
  }
194
- writeFileSync(join(outputDir, filename), fileContent);
213
+ const targetPath = join(outputDir, filename);
214
+ mkdirSync(dirname(targetPath), { recursive: true });
215
+ writeFileSync(targetPath, fileContent);
195
216
  }
196
217
  }
197
218
  } catch (err) {
@@ -0,0 +1,91 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import type { ParsedArgs } from "../registry";
3
+
4
+ const discoverOpsMock = vi.fn();
5
+ vi.mock("../../op/discover", () => ({
6
+ discoverOps: () => discoverOpsMock(),
7
+ }));
8
+
9
+ const { runGraph } = await import("./graph");
10
+
11
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
12
+ return {
13
+ command: "graph", path: ".",
14
+ format: "", fix: false, watch: false, verbose: false, help: false, live: false,
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ function makeOp(name: string, depends: string[] = []): [string, { config: { name: string; depends?: string[] } }] {
20
+ return [name, { config: { name, depends } }];
21
+ }
22
+
23
+ describe("runGraph", () => {
24
+ let stdoutBuf: string[];
25
+ let stderrBuf: string[];
26
+
27
+ beforeEach(() => {
28
+ stdoutBuf = [];
29
+ stderrBuf = [];
30
+ vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
31
+ vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
32
+ discoverOpsMock.mockReset();
33
+ });
34
+
35
+ test("prints 'No Ops found' when discovery is empty", async () => {
36
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
37
+ const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
38
+ expect(exit).toBe(0);
39
+ expect(stdoutBuf.join("\n")).toContain("No Ops found");
40
+ });
41
+
42
+ test("prints 'No Op dependencies' when ops have no depends", async () => {
43
+ discoverOpsMock.mockResolvedValue({
44
+ ops: new Map([makeOp("solo")]),
45
+ errors: [],
46
+ });
47
+ const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
48
+ expect(exit).toBe(0);
49
+ expect(stdoutBuf.join("\n")).toContain("No Op dependencies");
50
+ });
51
+
52
+ test("prints `dep -> name` edge per dependency", async () => {
53
+ discoverOpsMock.mockResolvedValue({
54
+ ops: new Map([
55
+ makeOp("infra"),
56
+ makeOp("app", ["infra"]),
57
+ ]),
58
+ errors: [],
59
+ });
60
+ const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
61
+ expect(exit).toBe(0);
62
+ const out = stdoutBuf.join("\n");
63
+ expect(out).toContain("infra → app");
64
+ });
65
+
66
+ test("handles multi-edge graphs", async () => {
67
+ discoverOpsMock.mockResolvedValue({
68
+ ops: new Map([
69
+ makeOp("a"),
70
+ makeOp("b", ["a"]),
71
+ makeOp("c", ["a", "b"]),
72
+ ]),
73
+ errors: [],
74
+ });
75
+ await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
76
+ const out = stdoutBuf.join("\n");
77
+ expect(out).toContain("a → b");
78
+ expect(out).toContain("a → c");
79
+ expect(out).toContain("b → c");
80
+ });
81
+
82
+ test("forwards discovery errors to stderr", async () => {
83
+ discoverOpsMock.mockResolvedValue({
84
+ ops: new Map(),
85
+ errors: ["failed to parse ops/bad.op.ts"],
86
+ });
87
+ const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
88
+ expect(exit).toBe(0);
89
+ expect(stderrBuf.join("\n")).toContain("failed to parse ops/bad.op.ts");
90
+ });
91
+ });
@@ -0,0 +1,23 @@
1
+ import { discoverOps } from "../../op/discover";
2
+ import { formatError } from "../format";
3
+ import type { CommandContext } from "../registry";
4
+
5
+ export async function runGraph(_ctx: CommandContext): Promise<number> {
6
+ const { ops, errors } = await discoverOps();
7
+ for (const err of errors) console.error(formatError({ message: err }));
8
+
9
+ if (ops.size === 0) {
10
+ console.log("No Ops found");
11
+ return 0;
12
+ }
13
+
14
+ let hasEdges = false;
15
+ for (const [name, { config }] of ops) {
16
+ for (const dep of config.depends ?? []) {
17
+ console.log(`${dep} → ${name}`);
18
+ hasEdges = true;
19
+ }
20
+ }
21
+ if (!hasEdges) console.log("No Op dependencies");
22
+ return 0;
23
+ }
@@ -0,0 +1,134 @@
1
+ /** Subset of WorkerProfile fields used by `chant run`. */
2
+ export interface WorkerProfile {
3
+ address: string;
4
+ namespace: string;
5
+ taskQueue: string;
6
+ tls?: boolean | { serverNameOverride?: string };
7
+ apiKey?: string | { env: string };
8
+ autoStart?: boolean;
9
+ }
10
+
11
+ export interface TemporalClientModule {
12
+ Connection: {
13
+ connect(opts: Record<string, unknown>): Promise<unknown>;
14
+ };
15
+ Client: new (opts: Record<string, unknown>) => TemporalClientHandle;
16
+ }
17
+
18
+ export interface TemporalClientHandle {
19
+ workflow: {
20
+ start(workflowFn: unknown, opts: Record<string, unknown>): Promise<WorkflowHandleRaw>;
21
+ getHandle(workflowId: string): WorkflowHandleRaw;
22
+ list(opts?: Record<string, unknown>): AsyncIterable<WorkflowExecutionInfo>;
23
+ };
24
+ }
25
+
26
+ export interface WorkflowHandleRaw {
27
+ workflowId: string;
28
+ firstExecutionRunId?: string;
29
+ result(): Promise<unknown>;
30
+ describe(): Promise<WorkflowExecutionDescription>;
31
+ fetchHistory(): Promise<WorkflowHistoryRaw>;
32
+ signal(signalName: string): Promise<void>;
33
+ cancel(): Promise<void>;
34
+ }
35
+
36
+ export interface WorkflowExecutionDescription {
37
+ workflowId: string;
38
+ runId: string;
39
+ status: { name: string };
40
+ startTime: Date;
41
+ closeTime?: Date;
42
+ taskQueue: string;
43
+ type: { name: string };
44
+ }
45
+
46
+ export interface WorkflowExecutionInfo {
47
+ workflowId: string;
48
+ runId: string;
49
+ type: { name: string };
50
+ status: { name: string };
51
+ startTime: Date;
52
+ closeTime?: Date;
53
+ }
54
+
55
+ export interface WorkflowHistoryRaw {
56
+ events?: HistoryEvent[];
57
+ }
58
+
59
+ export interface HistoryEvent {
60
+ eventType?: string;
61
+ eventTime?: Date;
62
+ activityTaskCompletedEventAttributes?: { scheduledEventId?: string | number };
63
+ activityTaskScheduledEventAttributes?: { activityId?: string; activityType?: { name?: string } };
64
+ activityTaskFailedEventAttributes?: { failure?: { message?: string } };
65
+ workflowExecutionCompletedEventAttributes?: unknown;
66
+ workflowExecutionFailedEventAttributes?: { failure?: { message?: string } };
67
+ }
68
+
69
+ /**
70
+ * Dynamically import @temporalio/client from the user's project node_modules.
71
+ * Fails with a helpful message if not installed.
72
+ */
73
+ export async function loadTemporalClient(): Promise<TemporalClientModule> {
74
+ try {
75
+ // Use variable to prevent tsc from statically resolving the optional dep
76
+ const mod = "@temporalio/client";
77
+ return await import(mod) as unknown as TemporalClientModule;
78
+ } catch {
79
+ throw new Error(
80
+ '@temporalio/client is not installed. Run: npm install @temporalio/client',
81
+ );
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Build a Temporal Connection.connect() options object from a worker profile.
87
+ */
88
+ export function connectionOptions(profile: WorkerProfile): Record<string, unknown> {
89
+ const apiKey =
90
+ typeof profile.apiKey === "object" && profile.apiKey !== null
91
+ ? process.env[(profile.apiKey as { env: string }).env]
92
+ : (profile.apiKey as string | undefined);
93
+
94
+ return {
95
+ address: profile.address,
96
+ ...(profile.tls && {
97
+ tls: typeof profile.tls === "object" ? profile.tls : {},
98
+ metadata: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
99
+ }),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Deterministic workflow ID for an Op — allows status/signal/cancel/log
105
+ * without storing run IDs locally.
106
+ */
107
+ export function resolveWorkflowId(opName: string): string {
108
+ return `chant-op-${opName}`;
109
+ }
110
+
111
+ /**
112
+ * Resolve a named profile from the chant config.
113
+ * Falls back to defaultProfile then "local".
114
+ */
115
+ export function resolveProfile(
116
+ config: Record<string, unknown>,
117
+ profileName?: string,
118
+ ): WorkerProfile {
119
+ const temporal = config.temporal as Record<string, unknown> | undefined;
120
+ if (!temporal?.profiles) {
121
+ throw new Error(
122
+ 'No temporal.profiles found in chant.config.ts. Add a profile to use `chant run`.',
123
+ );
124
+ }
125
+ const profiles = temporal.profiles as Record<string, WorkerProfile>;
126
+ const name = profileName ?? (temporal.defaultProfile as string | undefined) ?? "local";
127
+ const profile = profiles[name];
128
+ if (!profile) {
129
+ throw new Error(
130
+ `Temporal profile "${name}" not found. Available: ${Object.keys(profiles).join(", ")}`,
131
+ );
132
+ }
133
+ return profile;
134
+ }
@@ -0,0 +1,160 @@
1
+ import { writeFileSync, mkdirSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import type { OpConfig } from "../../op/types";
4
+ import type { WorkflowExecutionDescription, WorkflowHistoryRaw, HistoryEvent } from "./run-client";
5
+
6
+ interface ActivityRecord {
7
+ name: string;
8
+ startTime?: Date;
9
+ endTime?: Date;
10
+ durationMs?: number;
11
+ status: "completed" | "failed" | "running";
12
+ error?: string;
13
+ }
14
+
15
+ interface PhaseRecord {
16
+ name: string;
17
+ activities: ActivityRecord[];
18
+ }
19
+
20
+ function formatDuration(ms: number): string {
21
+ if (ms < 1000) return `${ms}ms`;
22
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
23
+ const mins = Math.floor(ms / 60_000);
24
+ const secs = Math.round((ms % 60_000) / 1000);
25
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
26
+ }
27
+
28
+ function extractPhaseRecords(config: OpConfig, history: WorkflowHistoryRaw): PhaseRecord[] {
29
+ const events = history.events ?? [];
30
+
31
+ // Build map: scheduled event ID → activity name
32
+ const scheduledActivities = new Map<string, string>();
33
+ const scheduledTimes = new Map<string, Date>();
34
+ const completedTimes = new Map<string, Date>();
35
+ const failedActivities = new Map<string, string>();
36
+
37
+ for (const event of events) {
38
+ if (event.eventType === "ActivityTaskScheduled" && event.activityTaskScheduledEventAttributes) {
39
+ const attrs = event.activityTaskScheduledEventAttributes;
40
+ const id = String(attrs.activityId ?? "");
41
+ const name = attrs.activityType?.name ?? "unknown";
42
+ scheduledActivities.set(id, name);
43
+ if (event.eventTime) scheduledTimes.set(id, new Date(event.eventTime));
44
+ }
45
+ if (event.eventType === "ActivityTaskCompleted" && event.activityTaskCompletedEventAttributes) {
46
+ const scheduledId = String(event.activityTaskCompletedEventAttributes.scheduledEventId ?? "");
47
+ if (event.eventTime) completedTimes.set(scheduledId, new Date(event.eventTime));
48
+ }
49
+ if (event.eventType === "ActivityTaskFailed" && event.activityTaskFailedEventAttributes) {
50
+ const msg = event.activityTaskFailedEventAttributes.failure?.message ?? "unknown error";
51
+ failedActivities.set(String(events.indexOf(event)), msg);
52
+ }
53
+ }
54
+
55
+ return config.phases.map((phase) => {
56
+ const activities: ActivityRecord[] = phase.steps
57
+ .filter((s) => s.kind === "activity")
58
+ .map((step) => {
59
+ if (step.kind !== "activity") return null;
60
+ const fn = step.fn;
61
+ // Find this activity by name in the history
62
+ let record: ActivityRecord | null = null;
63
+ for (const [id, name] of scheduledActivities) {
64
+ if (name === fn) {
65
+ const start = scheduledTimes.get(id);
66
+ const end = completedTimes.get(id);
67
+ const durationMs = start && end ? end.getTime() - start.getTime() : undefined;
68
+ record = {
69
+ name: fn,
70
+ startTime: start,
71
+ endTime: end,
72
+ durationMs,
73
+ status: end ? "completed" : "running",
74
+ };
75
+ }
76
+ }
77
+ return record ?? { name: fn, status: "running" };
78
+ })
79
+ .filter((r): r is ActivityRecord => r !== null);
80
+
81
+ return { name: phase.name, activities };
82
+ });
83
+ }
84
+
85
+ export function generateReport(
86
+ opName: string,
87
+ config: OpConfig,
88
+ description: WorkflowExecutionDescription,
89
+ history: WorkflowHistoryRaw,
90
+ ): string {
91
+ const lines: string[] = [];
92
+
93
+ const status = description.status.name;
94
+ const startTime = description.startTime;
95
+ const closeTime = description.closeTime;
96
+ const durationMs = closeTime ? closeTime.getTime() - startTime.getTime() : undefined;
97
+
98
+ lines.push(`# Deployment Report: ${opName}`);
99
+ lines.push("");
100
+ lines.push("## Overview");
101
+ lines.push("");
102
+ lines.push(`| Field | Value |`);
103
+ lines.push(`|---|---|`);
104
+ lines.push(`| Op | ${opName} |`);
105
+ lines.push(`| Overview | ${config.overview} |`);
106
+ lines.push(`| Status | **${status}** |`);
107
+ lines.push(`| Workflow ID | ${description.workflowId} |`);
108
+ lines.push(`| Run ID | ${description.runId} |`);
109
+ lines.push(`| Start | ${startTime.toISOString()} |`);
110
+ if (closeTime) lines.push(`| End | ${closeTime.toISOString()} |`);
111
+ if (durationMs !== undefined) lines.push(`| Duration | ${formatDuration(durationMs)} |`);
112
+ lines.push("");
113
+
114
+ lines.push("## Timeline");
115
+ lines.push("");
116
+ lines.push("| Phase | Activity | Duration | Status |");
117
+ lines.push("|---|---|---|---|");
118
+
119
+ const phases = extractPhaseRecords(config, history);
120
+ for (const phase of phases) {
121
+ if (phase.activities.length === 0) {
122
+ lines.push(`| ${phase.name} | — | — | — |`);
123
+ }
124
+ for (const act of phase.activities) {
125
+ const dur = act.durationMs !== undefined ? formatDuration(act.durationMs) : "—";
126
+ const statusEmoji = act.status === "completed" ? "✓" : act.status === "failed" ? "✗" : "…";
127
+ lines.push(`| ${phase.name} | ${act.name} | ${dur} | ${statusEmoji} ${act.status} |`);
128
+ }
129
+ }
130
+ lines.push("");
131
+
132
+ // Errors section
133
+ const failedEvents = (history.events ?? []).filter(
134
+ (e): e is HistoryEvent & Required<Pick<HistoryEvent, "activityTaskFailedEventAttributes">> =>
135
+ e.eventType === "ActivityTaskFailed" && !!e.activityTaskFailedEventAttributes,
136
+ );
137
+
138
+ if (failedEvents.length > 0) {
139
+ lines.push("## Errors");
140
+ lines.push("");
141
+ for (const event of failedEvents) {
142
+ const msg = event.activityTaskFailedEventAttributes?.failure?.message ?? "unknown";
143
+ lines.push(`- ${msg}`);
144
+ }
145
+ lines.push("");
146
+ }
147
+
148
+ lines.push(`---`);
149
+ lines.push(`*Generated by chant at ${new Date().toISOString()}*`);
150
+ lines.push("");
151
+
152
+ return lines.join("\n");
153
+ }
154
+
155
+ export function writeReport(opName: string, markdown: string): string {
156
+ const outPath = join("dist", `${opName}-report.md`);
157
+ mkdirSync(dirname(outPath), { recursive: true });
158
+ writeFileSync(outPath, markdown, "utf-8");
159
+ return outPath;
160
+ }