@intentius/chant 0.1.5 → 0.1.7
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 +2 -1
- package/src/cli/commands/build.ts +17 -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.ts +453 -0
- package/src/cli/main.test.ts +64 -0
- package/src/cli/main.ts +28 -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/registry.ts +2 -0
- package/src/composite.ts +10 -5
- package/src/index.ts +4 -1
- package/src/op/builders.ts +96 -0
- package/src/op/discover.test.ts +43 -0
- package/src/op/discover.ts +89 -0
- package/src/op/index.ts +6 -0
- package/src/op/op.test.ts +199 -0
- package/src/op/resource.ts +8 -0
- package/src/op/types.ts +60 -0
- 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
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { resolve, join } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createConnection } from "node:net";
|
|
4
|
+
import { spawn as spawnChild, type ChildProcess } from "node:child_process";
|
|
5
|
+
import { loadChantConfig } from "../../config";
|
|
6
|
+
import { discoverOps } from "../../op/discover";
|
|
7
|
+
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
8
|
+
import type { CommandContext } from "../registry";
|
|
9
|
+
import {
|
|
10
|
+
loadTemporalClient,
|
|
11
|
+
connectionOptions,
|
|
12
|
+
resolveWorkflowId,
|
|
13
|
+
resolveProfile,
|
|
14
|
+
type WorkflowHandleRaw,
|
|
15
|
+
type WorkflowExecutionDescription,
|
|
16
|
+
type WorkflowHistoryRaw,
|
|
17
|
+
} from "./run-client";
|
|
18
|
+
import { generateReport, writeReport } from "./run-report";
|
|
19
|
+
|
|
20
|
+
function kebabToCamel(s: string): string {
|
|
21
|
+
return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function workflowFnName(opName: string): string {
|
|
25
|
+
return kebabToCamel(opName) + "Workflow";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function makeTemporalClient(profileName: string | undefined, projectPath: string) {
|
|
29
|
+
const { config } = await loadChantConfig(projectPath);
|
|
30
|
+
const profile = resolveProfile(config as Record<string, unknown>, profileName);
|
|
31
|
+
const { Connection, Client } = await loadTemporalClient();
|
|
32
|
+
const connection = await Connection.connect(connectionOptions(profile));
|
|
33
|
+
const client = new Client({ connection, namespace: profile.namespace });
|
|
34
|
+
return { client, profile, config };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── chant run list ──────────────────────────────────���─────────────────────────
|
|
38
|
+
|
|
39
|
+
export async function runOpList(ctx: CommandContext): Promise<number> {
|
|
40
|
+
const { ops, errors } = await discoverOps();
|
|
41
|
+
|
|
42
|
+
for (const err of errors) {
|
|
43
|
+
console.error(formatError({ message: err }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (ops.size === 0) {
|
|
47
|
+
console.error(formatWarning({ message: "No Op definitions found (*.op.ts)" }));
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(
|
|
52
|
+
"NAME".padEnd(22) +
|
|
53
|
+
"PHASES".padEnd(8) +
|
|
54
|
+
"TASK-QUEUE".padEnd(20) +
|
|
55
|
+
"DEPENDS".padEnd(20) +
|
|
56
|
+
"OVERVIEW",
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
let runStatus: Map<string, string> | undefined;
|
|
60
|
+
try {
|
|
61
|
+
const projectPath = resolve(".");
|
|
62
|
+
const { config } = await loadChantConfig(projectPath);
|
|
63
|
+
const profile = resolveProfile(config as Record<string, unknown>, ctx.args.profile);
|
|
64
|
+
const { Connection, Client } = await loadTemporalClient();
|
|
65
|
+
const connection = await Connection.connect(connectionOptions(profile));
|
|
66
|
+
const client = new Client({ connection, namespace: profile.namespace });
|
|
67
|
+
runStatus = new Map();
|
|
68
|
+
for (const [name] of ops) {
|
|
69
|
+
try {
|
|
70
|
+
const desc = await client.workflow.getHandle(resolveWorkflowId(name)).describe();
|
|
71
|
+
runStatus.set(name, desc.status.name);
|
|
72
|
+
} catch {
|
|
73
|
+
runStatus.set(name, "—");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Temporal not available — degrade gracefully
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const [name, { config }] of ops) {
|
|
81
|
+
const phases = String(config.phases.length);
|
|
82
|
+
const tq = config.taskQueue ?? config.name;
|
|
83
|
+
const deps = config.depends?.join(",") ?? "—";
|
|
84
|
+
const overview = config.overview.length > 36
|
|
85
|
+
? config.overview.slice(0, 33) + "..."
|
|
86
|
+
: config.overview;
|
|
87
|
+
const status = runStatus?.get(name);
|
|
88
|
+
const statusStr = status ? ` [${status}]` : "";
|
|
89
|
+
|
|
90
|
+
console.log(
|
|
91
|
+
(name + statusStr).padEnd(22) +
|
|
92
|
+
phases.padEnd(8) +
|
|
93
|
+
tq.padEnd(20) +
|
|
94
|
+
deps.padEnd(20) +
|
|
95
|
+
overview,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── chant run status <name> ───────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export async function runOpStatus(ctx: CommandContext): Promise<number> {
|
|
105
|
+
const name = ctx.args.extraPositional;
|
|
106
|
+
if (!name) {
|
|
107
|
+
console.error(formatError({ message: "Op name is required: chant run status <name>" }));
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const projectPath = resolve(".");
|
|
112
|
+
let client, desc: WorkflowExecutionDescription, history: WorkflowHistoryRaw;
|
|
113
|
+
try {
|
|
114
|
+
({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
|
|
115
|
+
const handle = client.workflow.getHandle(resolveWorkflowId(name));
|
|
116
|
+
desc = await handle.describe();
|
|
117
|
+
history = await handle.fetchHistory();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(formatBold(`Op: ${name}`));
|
|
124
|
+
console.log(` Workflow ID : ${desc.workflowId}`);
|
|
125
|
+
console.log(` Run ID : ${desc.runId}`);
|
|
126
|
+
console.log(` Status : ${desc.status.name}`);
|
|
127
|
+
console.log(` Task Queue : ${desc.taskQueue}`);
|
|
128
|
+
console.log(` Started : ${desc.startTime.toISOString()}`);
|
|
129
|
+
if (desc.closeTime) console.log(` Closed : ${desc.closeTime.toISOString()}`);
|
|
130
|
+
|
|
131
|
+
const events = history.events ?? [];
|
|
132
|
+
const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
|
|
133
|
+
const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
|
|
134
|
+
if (scheduled > 0) {
|
|
135
|
+
console.log(` Activities : ${completed}/${scheduled} completed`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── chant run log <name> ──────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export async function runOpLog(ctx: CommandContext): Promise<number> {
|
|
144
|
+
const name = ctx.args.extraPositional;
|
|
145
|
+
if (!name) {
|
|
146
|
+
console.error(formatError({ message: "Op name is required: chant run log <name>" }));
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const projectPath = resolve(".");
|
|
151
|
+
let client;
|
|
152
|
+
try {
|
|
153
|
+
({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log(
|
|
160
|
+
"RUN-ID".padEnd(36) +
|
|
161
|
+
"STATUS".padEnd(16) +
|
|
162
|
+
"STARTED".padEnd(26) +
|
|
163
|
+
"CLOSED",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const fnName = workflowFnName(name);
|
|
168
|
+
for await (const run of client.workflow.list({ query: `WorkflowType = "${fnName}"` })) {
|
|
169
|
+
const start = run.startTime.toISOString().slice(0, 19).replace("T", " ");
|
|
170
|
+
const close = run.closeTime ? run.closeTime.toISOString().slice(0, 19).replace("T", " ") : "—";
|
|
171
|
+
console.log(
|
|
172
|
+
run.runId.padEnd(36) +
|
|
173
|
+
run.status.name.padEnd(16) +
|
|
174
|
+
start.padEnd(26) +
|
|
175
|
+
close,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── chant run signal <name> <signal> ─────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export async function runOpSignal(ctx: CommandContext): Promise<number> {
|
|
189
|
+
const name = ctx.args.extraPositional;
|
|
190
|
+
const signalName = ctx.args.extraPositional2;
|
|
191
|
+
|
|
192
|
+
if (!name || !signalName) {
|
|
193
|
+
console.error(formatError({ message: "Usage: chant run signal <op-name> <signal-name>" }));
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const projectPath = resolve(".");
|
|
198
|
+
let handle: WorkflowHandleRaw;
|
|
199
|
+
try {
|
|
200
|
+
const { client } = await makeTemporalClient(ctx.args.profile, projectPath);
|
|
201
|
+
handle = client.workflow.getHandle(resolveWorkflowId(name));
|
|
202
|
+
await handle.signal(signalName);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.error(formatSuccess(`Signal "${signalName}" sent to Op "${name}"`));
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── chant run cancel <name> ───────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
export async function runOpCancel(ctx: CommandContext): Promise<number> {
|
|
215
|
+
const name = ctx.args.extraPositional;
|
|
216
|
+
if (!name) {
|
|
217
|
+
console.error(formatError({ message: "Op name is required: chant run cancel <name>" }));
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!ctx.args.force) {
|
|
222
|
+
console.error(formatWarning({
|
|
223
|
+
message: `Cancelling "${name}" will stop the active workflow run`,
|
|
224
|
+
hint: "Use --force to confirm cancellation",
|
|
225
|
+
}));
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const projectPath = resolve(".");
|
|
230
|
+
let handle: WorkflowHandleRaw;
|
|
231
|
+
try {
|
|
232
|
+
const { client } = await makeTemporalClient(ctx.args.profile, projectPath);
|
|
233
|
+
handle = client.workflow.getHandle(resolveWorkflowId(name));
|
|
234
|
+
await handle.cancel();
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.error(formatSuccess(`Cancellation requested for Op "${name}"`));
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── chant run <name> — main command ───────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
const TERMINAL_STATUSES = new Set(["COMPLETED", "FAILED", "CANCELLED", "TERMINATED", "TIMED_OUT"]);
|
|
247
|
+
const POLL_INTERVAL_MS = 3000;
|
|
248
|
+
|
|
249
|
+
async function waitForTemporalServer(address: string, maxWaitMs = 30_000): Promise<void> {
|
|
250
|
+
const [host, portStr] = address.split(":");
|
|
251
|
+
const port = parseInt(portStr ?? "7233", 10);
|
|
252
|
+
const deadline = Date.now() + maxWaitMs;
|
|
253
|
+
while (Date.now() < deadline) {
|
|
254
|
+
try {
|
|
255
|
+
await new Promise<void>((res, rej) => {
|
|
256
|
+
const socket = createConnection({ host, port }, () => { socket.destroy(); res(); });
|
|
257
|
+
socket.on("error", rej);
|
|
258
|
+
socket.setTimeout(1000, () => { socket.destroy(); rej(new Error("timeout")); });
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
} catch {
|
|
262
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
throw new Error(`Temporal server at ${address} did not become ready within ${maxWaitMs / 1000}s`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
|
|
269
|
+
const events = history.events ?? [];
|
|
270
|
+
const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
|
|
271
|
+
const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
|
|
272
|
+
process.stderr.write(
|
|
273
|
+
`\r${formatInfo(`[${opName}]`)} ${completed}/${scheduled} activities completed`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function runOp(ctx: CommandContext): Promise<number> {
|
|
278
|
+
const opName = ctx.args.path;
|
|
279
|
+
|
|
280
|
+
if (!opName || opName === ".") {
|
|
281
|
+
console.error(formatError({
|
|
282
|
+
message: "Op name is required: chant run <name>",
|
|
283
|
+
hint: "Run `chant run list` to see available Ops",
|
|
284
|
+
}));
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Discover Ops
|
|
289
|
+
const { ops, errors } = await discoverOps();
|
|
290
|
+
for (const err of errors) console.error(formatWarning({ message: err }));
|
|
291
|
+
|
|
292
|
+
const discovered = ops.get(opName);
|
|
293
|
+
if (!discovered) {
|
|
294
|
+
const names = [...ops.keys()];
|
|
295
|
+
console.error(formatError({
|
|
296
|
+
message: `Op "${opName}" not found`,
|
|
297
|
+
hint: names.length > 0
|
|
298
|
+
? `Available: ${names.join(", ")}`
|
|
299
|
+
: "No *.op.ts files found — create one or run `chant run list`",
|
|
300
|
+
}));
|
|
301
|
+
return 1;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const { config } = discovered;
|
|
305
|
+
const projectPath = resolve(".");
|
|
306
|
+
|
|
307
|
+
// Load config + profile
|
|
308
|
+
const { config: chantConfig } = await loadChantConfig(projectPath);
|
|
309
|
+
let profile;
|
|
310
|
+
try {
|
|
311
|
+
profile = resolveProfile(chantConfig as Record<string, unknown>, ctx.args.profile);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Handle --report flag: just print the last run report
|
|
318
|
+
if (ctx.args.report) {
|
|
319
|
+
let client, desc: WorkflowExecutionDescription, history: WorkflowHistoryRaw;
|
|
320
|
+
try {
|
|
321
|
+
({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
|
|
322
|
+
const handle = client.workflow.getHandle(resolveWorkflowId(opName));
|
|
323
|
+
desc = await handle.describe();
|
|
324
|
+
history = await handle.fetchHistory();
|
|
325
|
+
} catch (err) {
|
|
326
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
327
|
+
return 1;
|
|
328
|
+
}
|
|
329
|
+
const md = generateReport(opName, config, desc, history);
|
|
330
|
+
process.stdout.write(md);
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Check built worker exists
|
|
335
|
+
const workerPath = join(projectPath, "dist", "ops", opName, "worker.ts");
|
|
336
|
+
if (!existsSync(workerPath)) {
|
|
337
|
+
console.error(formatError({
|
|
338
|
+
message: `dist/ops/${opName}/worker.ts not found`,
|
|
339
|
+
hint: "Run `chant build` first to generate the worker",
|
|
340
|
+
}));
|
|
341
|
+
return 1;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// autoStart: spin up temporal server if needed
|
|
345
|
+
if (profile.autoStart) {
|
|
346
|
+
console.error(formatInfo("autoStart: checking Temporal server..."));
|
|
347
|
+
try {
|
|
348
|
+
await waitForTemporalServer(profile.address, 2000);
|
|
349
|
+
console.error(formatInfo("Temporal server already running."));
|
|
350
|
+
} catch {
|
|
351
|
+
console.error(formatInfo("Starting temporal server start-dev..."));
|
|
352
|
+
spawnChild("temporal", ["server", "start-dev"], {
|
|
353
|
+
cwd: projectPath,
|
|
354
|
+
stdio: "ignore",
|
|
355
|
+
detached: true,
|
|
356
|
+
}).unref();
|
|
357
|
+
await waitForTemporalServer(profile.address, 30_000);
|
|
358
|
+
console.error(formatSuccess("Temporal server ready."));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Load Temporal client
|
|
363
|
+
let client;
|
|
364
|
+
try {
|
|
365
|
+
({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Spawn worker process
|
|
372
|
+
const profileName = ctx.args.profile ??
|
|
373
|
+
(((chantConfig as Record<string, unknown>).temporal as Record<string, unknown> | undefined)?.defaultProfile as string | undefined) ??
|
|
374
|
+
"local";
|
|
375
|
+
|
|
376
|
+
console.error(formatInfo(`Spawning worker for Op "${opName}" (profile: ${profileName})...`));
|
|
377
|
+
const workerProcess: ChildProcess = spawnChild("npx", ["tsx", workerPath], {
|
|
378
|
+
cwd: projectPath,
|
|
379
|
+
env: { ...process.env, TEMPORAL_PROFILE: profileName },
|
|
380
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Submit workflow
|
|
384
|
+
const workflowId = resolveWorkflowId(opName);
|
|
385
|
+
const fnName = workflowFnName(opName);
|
|
386
|
+
const taskQueue = profile.taskQueue ?? opName;
|
|
387
|
+
|
|
388
|
+
let handle: WorkflowHandleRaw;
|
|
389
|
+
try {
|
|
390
|
+
handle = await client.workflow.start(fnName, {
|
|
391
|
+
taskQueue,
|
|
392
|
+
workflowId,
|
|
393
|
+
workflowIdConflictPolicy: "FAIL",
|
|
394
|
+
});
|
|
395
|
+
console.error(formatSuccess(`Workflow started: ${workflowId}`));
|
|
396
|
+
} catch (err) {
|
|
397
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Poll for progress until terminal state
|
|
402
|
+
let finalDesc: WorkflowExecutionDescription | undefined;
|
|
403
|
+
let finalHistory: WorkflowHistoryRaw | undefined;
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
while (true) {
|
|
407
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
408
|
+
const desc = await handle.describe();
|
|
409
|
+
const history = await handle.fetchHistory();
|
|
410
|
+
|
|
411
|
+
renderProgress(opName, history);
|
|
412
|
+
|
|
413
|
+
if (TERMINAL_STATUSES.has(desc.status.name)) {
|
|
414
|
+
process.stderr.write("\n");
|
|
415
|
+
finalDesc = desc;
|
|
416
|
+
finalHistory = history;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} catch (err) {
|
|
421
|
+
process.stderr.write("\n");
|
|
422
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
423
|
+
return 1;
|
|
424
|
+
} finally {
|
|
425
|
+
// Kill worker process (best-effort)
|
|
426
|
+
try { workerProcess.kill(); } catch { /* ignore */ }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!finalDesc || !finalHistory) return 1;
|
|
430
|
+
|
|
431
|
+
const status = finalDesc.status.name;
|
|
432
|
+
console.error(status === "COMPLETED"
|
|
433
|
+
? formatSuccess(`Op "${opName}" completed successfully.`)
|
|
434
|
+
: formatError({ message: `Op "${opName}" ended with status: ${status}` }),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// Write deployment report
|
|
438
|
+
const md = generateReport(opName, config, finalDesc, finalHistory);
|
|
439
|
+
const reportPath = writeReport(opName, md);
|
|
440
|
+
console.error(formatInfo(`Report written to ${reportPath}`));
|
|
441
|
+
|
|
442
|
+
return status === "COMPLETED" ? 0 : 1;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── fallback ────────────────────────────────────────────────────────────────���─
|
|
446
|
+
|
|
447
|
+
export function runOpUnknown(ctx: CommandContext): Promise<number> {
|
|
448
|
+
console.error(formatError({
|
|
449
|
+
message: `Unknown run subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
|
|
450
|
+
hint: "Available: chant run <name>, run list, run status, run signal, run cancel, run log",
|
|
451
|
+
}));
|
|
452
|
+
return Promise.resolve(1);
|
|
453
|
+
}
|
package/src/cli/main.test.ts
CHANGED
|
@@ -254,4 +254,68 @@ describe("resolveCommand", () => {
|
|
|
254
254
|
expect(result!.def.name).toBe("dev generate");
|
|
255
255
|
expect(result!.compound).toBe(true);
|
|
256
256
|
});
|
|
257
|
+
|
|
258
|
+
test("resolves run status as compound command", () => {
|
|
259
|
+
const registry: CommandDef[] = [
|
|
260
|
+
{ name: "run list", handler: noop },
|
|
261
|
+
{ name: "run status", handler: noop },
|
|
262
|
+
{ name: "run signal", handler: noop },
|
|
263
|
+
{ name: "run cancel", handler: noop },
|
|
264
|
+
{ name: "run log", handler: noop },
|
|
265
|
+
{ name: "run", handler: noop },
|
|
266
|
+
];
|
|
267
|
+
const result = resolveCommand(makeArgs({ command: "run", path: "status" }), registry);
|
|
268
|
+
expect(result!.def.name).toBe("run status");
|
|
269
|
+
expect(result!.compound).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("resolves run <name> as simple command", () => {
|
|
273
|
+
const registry: CommandDef[] = [
|
|
274
|
+
{ name: "run list", handler: noop },
|
|
275
|
+
{ name: "run status", handler: noop },
|
|
276
|
+
{ name: "run", handler: noop },
|
|
277
|
+
];
|
|
278
|
+
const result = resolveCommand(makeArgs({ command: "run", path: "alb-deploy" }), registry);
|
|
279
|
+
expect(result!.def.name).toBe("run");
|
|
280
|
+
expect(result!.compound).toBe(false);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("parseArgs — run flags", () => {
|
|
285
|
+
test("parses --profile flag", () => {
|
|
286
|
+
const result = parseArgs(["run", "alb-deploy", "--profile", "local"]);
|
|
287
|
+
expect(result.command).toBe("run");
|
|
288
|
+
expect(result.path).toBe("alb-deploy");
|
|
289
|
+
expect(result.profile).toBe("local");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("parses -p shorthand for --profile", () => {
|
|
293
|
+
const result = parseArgs(["run", "alb-deploy", "-p", "cloud"]);
|
|
294
|
+
expect(result.profile).toBe("cloud");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("parses --report flag", () => {
|
|
298
|
+
const result = parseArgs(["run", "alb-deploy", "--report"]);
|
|
299
|
+
expect(result.command).toBe("run");
|
|
300
|
+
expect(result.path).toBe("alb-deploy");
|
|
301
|
+
expect(result.report).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("report is undefined when not provided", () => {
|
|
305
|
+
const result = parseArgs(["run", "alb-deploy"]);
|
|
306
|
+
expect(result.report).toBe(undefined);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("profile is undefined when not provided", () => {
|
|
310
|
+
const result = parseArgs(["run", "alb-deploy"]);
|
|
311
|
+
expect(result.profile).toBe(undefined);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("run signal parses op name and signal into positionals", () => {
|
|
315
|
+
const result = parseArgs(["run", "signal", "alb-deploy", "gate-dns"]);
|
|
316
|
+
expect(result.command).toBe("run");
|
|
317
|
+
expect(result.path).toBe("signal");
|
|
318
|
+
expect(result.extraPositional).toBe("alb-deploy");
|
|
319
|
+
expect(result.extraPositional2).toBe("gate-dns");
|
|
320
|
+
});
|
|
257
321
|
});
|
package/src/cli/main.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { runServeLsp, runServeMcp, runServeUnknown } from "./handlers/serve";
|
|
|
13
13
|
import { runInit, runInitLexicon } from "./handlers/init";
|
|
14
14
|
import { runList, runImport, runUpdate, runDoctor } from "./handlers/misc";
|
|
15
15
|
import { runStateSnapshot, runStateShow, runStateDiff, runStateLog, runStateUnknown } from "./handlers/state";
|
|
16
|
-
import {
|
|
16
|
+
import { runGraph } from "./handlers/graph";
|
|
17
|
+
import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Parse command line arguments
|
|
@@ -33,6 +34,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
33
34
|
watch: false,
|
|
34
35
|
verbose: false,
|
|
35
36
|
help: false,
|
|
37
|
+
profile: undefined,
|
|
38
|
+
report: undefined,
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
let i = 0;
|
|
@@ -57,6 +60,10 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
57
60
|
result.watch = true;
|
|
58
61
|
} else if (arg === "--verbose" || arg === "-v") {
|
|
59
62
|
result.verbose = true;
|
|
63
|
+
} else if (arg === "--profile" || arg === "-p") {
|
|
64
|
+
result.profile = args[++i];
|
|
65
|
+
} else if (arg === "--report") {
|
|
66
|
+
result.report = true;
|
|
60
67
|
} else if (!arg.startsWith("-")) {
|
|
61
68
|
if (!result.command) {
|
|
62
69
|
result.command = arg;
|
|
@@ -93,14 +100,15 @@ Commands:
|
|
|
93
100
|
list List discovered entities
|
|
94
101
|
import Import external template into TypeScript
|
|
95
102
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
Ops:
|
|
104
|
+
run <name> Start an Op workflow (spawns worker + submits to Temporal)
|
|
105
|
+
run list List all Ops with current run status
|
|
106
|
+
run status <name> Show current workflow run state
|
|
107
|
+
run signal <name> <signal> Send a named signal to unblock a gate
|
|
108
|
+
run cancel <name> Cancel the active workflow run (requires --force)
|
|
109
|
+
run log <name> Show run history for an Op
|
|
110
|
+
|
|
111
|
+
graph Show Op dependency graph
|
|
104
112
|
|
|
105
113
|
State:
|
|
106
114
|
state snapshot <env> Query API, save metadata to orphan branch
|
|
@@ -135,6 +143,8 @@ Options:
|
|
|
135
143
|
-w, --watch Watch for changes and rebuild/re-lint (build, lint)
|
|
136
144
|
-v, --verbose Show stack traces on errors
|
|
137
145
|
-h, --help Show this help message
|
|
146
|
+
-p, --profile <name> Temporal worker profile to use (run command)
|
|
147
|
+
--report Print deployment report instead of running (run command)
|
|
138
148
|
|
|
139
149
|
Examples:
|
|
140
150
|
chant build ./infra/
|
|
@@ -194,13 +204,14 @@ const registry: CommandDef[] = [
|
|
|
194
204
|
{ name: "dev onboard", handler: runDevOnboard },
|
|
195
205
|
{ name: "dev check-lexicon", handler: runDevCheckLexicon },
|
|
196
206
|
|
|
197
|
-
//
|
|
198
|
-
{ name: "
|
|
199
|
-
{ name: "
|
|
200
|
-
{ name: "
|
|
201
|
-
{ name: "
|
|
202
|
-
{ name: "
|
|
203
|
-
{ name: "
|
|
207
|
+
// Op / run subcommands
|
|
208
|
+
{ name: "run list", handler: runOpList },
|
|
209
|
+
{ name: "run status", handler: runOpStatus },
|
|
210
|
+
{ name: "run signal", handler: runOpSignal },
|
|
211
|
+
{ name: "run cancel", handler: runOpCancel },
|
|
212
|
+
{ name: "run log", handler: runOpLog },
|
|
213
|
+
{ name: "run", handler: runOp },
|
|
214
|
+
|
|
204
215
|
{ name: "graph", handler: runGraph },
|
|
205
216
|
|
|
206
217
|
// State subcommands
|
|
@@ -214,7 +225,6 @@ const registry: CommandDef[] = [
|
|
|
214
225
|
{ name: "serve mcp", requiresPlugins: true, handler: runServeMcp },
|
|
215
226
|
|
|
216
227
|
// Fallback for unknown subcommands (must come after compound entries)
|
|
217
|
-
{ name: "spell", handler: runSpellUnknown },
|
|
218
228
|
{ name: "state", handler: runStateUnknown },
|
|
219
229
|
{ name: "dev", handler: runDevUnknown },
|
|
220
230
|
{ name: "serve", handler: runServeUnknown },
|
|
@@ -250,7 +260,7 @@ async function main(): Promise<void> {
|
|
|
250
260
|
process.exit(1);
|
|
251
261
|
}
|
|
252
262
|
|
|
253
|
-
// For compound commands (e.g. "
|
|
263
|
+
// For compound commands (e.g. "run list"), args.path is the subcommand,
|
|
254
264
|
// so always use "." as the project path. For simple commands, use args.path.
|
|
255
265
|
const projectPath = match.compound ? (args.extraPositional || ".") : args.path;
|
|
256
266
|
const plugins = match.def.requiresPlugins
|