@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.
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +58 -5
- package/src/cli/commands/build.ts +24 -3
- package/src/cli/handlers/graph.test.ts +91 -0
- package/src/cli/handlers/graph.ts +23 -0
- package/src/cli/handlers/run-client.ts +134 -0
- package/src/cli/handlers/run-report.ts +160 -0
- package/src/cli/handlers/run.test.ts +448 -0
- package/src/cli/handlers/run.ts +453 -0
- package/src/cli/handlers/state.test.ts +409 -0
- package/src/cli/handlers/state.ts +232 -10
- package/src/cli/main.test.ts +65 -0
- package/src/cli/main.ts +32 -18
- package/src/cli/mcp/op-tools.ts +204 -0
- package/src/cli/mcp/resource-handlers.ts +69 -50
- package/src/cli/mcp/resources/context.ts +27 -0
- package/src/cli/mcp/server.test.ts +176 -3
- package/src/cli/mcp/server.ts +7 -3
- package/src/cli/mcp/state-tools.ts +0 -51
- package/src/cli/mcp/tools/search.ts +6 -1
- package/src/cli/registry.ts +3 -0
- package/src/composite.ts +10 -5
- package/src/index.ts +1 -2
- package/src/lexicon-plugin-helpers.ts +13 -5
- package/src/lexicon.ts +57 -1
- package/src/lint/config.test.ts +21 -0
- package/src/lint/config.ts +19 -3
- package/src/op/discover.test.ts +43 -0
- package/src/op/discover.ts +89 -0
- package/src/op/index.ts +3 -1
- package/src/op/types.ts +13 -6
- package/src/state/digest.test.ts +117 -0
- package/src/state/git.test.ts +191 -0
- package/src/state/git.ts +63 -11
- package/src/state/live-diff.test.ts +184 -0
- package/src/state/live-diff.ts +215 -0
- package/src/state/snapshot.test.ts +171 -0
- package/src/state/snapshot.ts +39 -19
- package/src/state/types.ts +4 -2
- package/src/cli/handlers/spell.ts +0 -396
- package/src/spell/discovery.ts +0 -183
- package/src/spell/index.ts +0 -3
- package/src/spell/prompt.ts +0 -133
- package/src/spell/types.ts +0 -89
package/package.json
CHANGED
|
@@ -132,18 +132,71 @@ export const testEntity = {
|
|
|
132
132
|
expect(result.fileCount).toBeDefined();
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
test("
|
|
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:
|
|
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
|
-
|
|
146
|
-
expect(
|
|
147
|
-
|
|
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
|
-
|
|
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
|
+
}
|