@intentius/chant 0.1.14 → 0.1.16
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/build.ts +18 -2
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/import-live.test.ts +126 -0
- package/src/cli/commands/import.ts +152 -2
- package/src/cli/commands/migrate.ts +2 -2
- package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +80 -37
- package/src/cli/handlers/{state.ts → lifecycle.ts} +166 -40
- package/src/cli/handlers/misc.ts +31 -2
- package/src/cli/handlers/run.test.ts +98 -0
- package/src/cli/handlers/run.ts +123 -0
- package/src/cli/main.test.ts +14 -0
- package/src/cli/main.ts +49 -15
- package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
- package/src/cli/mcp/op-tools.ts +2 -2
- package/src/cli/mcp/resource-handlers.ts +1 -1
- package/src/cli/mcp/server.test.ts +2 -2
- package/src/cli/mcp/server.ts +1 -1
- package/src/cli/registry.ts +23 -2
- package/src/codegen/fetch.test.ts +103 -2
- package/src/codegen/fetch.ts +62 -10
- package/src/config.ts +41 -0
- package/src/detectLexicon.test.ts +2 -2
- package/src/index.ts +2 -2
- package/src/lexicon-export.test.ts +92 -0
- package/src/lexicon.ts +88 -9
- package/src/lifecycle/change-set.test.ts +151 -0
- package/src/lifecycle/change-set.ts +172 -0
- package/src/{state → lifecycle}/git.test.ts +15 -15
- package/src/{state → lifecycle}/git.ts +14 -14
- package/src/{state → lifecycle}/index.ts +2 -0
- package/src/{state → lifecycle}/snapshot.test.ts +5 -5
- package/src/{state → lifecycle}/snapshot.ts +9 -9
- package/src/{state → lifecycle}/types.ts +1 -1
- package/src/op/activity-registry.test.ts +59 -0
- package/src/op/activity-registry.ts +98 -0
- package/src/op/builders.ts +56 -20
- package/src/op/index.ts +6 -1
- package/src/op/local-executor.test.ts +263 -0
- package/src/op/local-executor.ts +300 -0
- package/src/op/local-output.test.ts +54 -0
- package/src/op/local-output.ts +63 -0
- package/src/op/op.test.ts +41 -4
- package/src/op/types.ts +2 -2
- package/src/ownership.test.ts +109 -0
- package/src/ownership.ts +142 -0
- package/src/serializer.ts +19 -1
- package/src/toml-parse.ts +3 -3
- /package/src/{state → lifecycle}/digest.test.ts +0 -0
- /package/src/{state → lifecycle}/digest.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.ts +0 -0
package/src/cli/handlers/run.ts
CHANGED
|
@@ -4,6 +4,9 @@ import { createConnection } from "node:net";
|
|
|
4
4
|
import { spawn as spawnChild, type ChildProcess } from "node:child_process";
|
|
5
5
|
import { loadChantConfig } from "../../config";
|
|
6
6
|
import { discoverOps } from "../../op/discover";
|
|
7
|
+
import { loadActivities, loadProfiles } from "../../op/activity-registry";
|
|
8
|
+
import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure } from "../../op/local-executor";
|
|
9
|
+
import { renderHuman, renderJson } from "../../op/local-output";
|
|
7
10
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
8
11
|
import type { CommandContext } from "../registry";
|
|
9
12
|
import {
|
|
@@ -25,6 +28,21 @@ function workflowFnName(opName: string): string {
|
|
|
25
28
|
return kebabToCamel(opName) + "Workflow";
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Guard for Temporal-only commands (run list/status/log/signal/cancel, --report).
|
|
33
|
+
* These query durable run state that only exists under Temporal. Returns true
|
|
34
|
+
* when `--temporal` was passed; otherwise prints an actionable message and the
|
|
35
|
+
* caller should return non-zero.
|
|
36
|
+
*/
|
|
37
|
+
function requireTemporalMode(ctx: CommandContext, what: string): boolean {
|
|
38
|
+
if (ctx.args.temporal) return true;
|
|
39
|
+
console.error(formatError({
|
|
40
|
+
message: `\`${what}\` is not available in local mode`,
|
|
41
|
+
hint: "pass --temporal or configure a profile",
|
|
42
|
+
}));
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
export async function makeTemporalClient(profileName: string | undefined, projectPath: string) {
|
|
29
47
|
const { config } = await loadChantConfig(projectPath);
|
|
30
48
|
const profile = resolveProfile(config as Record<string, unknown>, profileName);
|
|
@@ -37,6 +55,7 @@ export async function makeTemporalClient(profileName: string | undefined, projec
|
|
|
37
55
|
// ── chant run list ──────────────────────────────────���─────────────────────────
|
|
38
56
|
|
|
39
57
|
export async function runOpList(ctx: CommandContext): Promise<number> {
|
|
58
|
+
if (!requireTemporalMode(ctx, "chant run list")) return 1;
|
|
40
59
|
const { ops, errors } = await discoverOps();
|
|
41
60
|
|
|
42
61
|
for (const err of errors) {
|
|
@@ -102,6 +121,7 @@ export async function runOpList(ctx: CommandContext): Promise<number> {
|
|
|
102
121
|
// ── chant run status <name> ───────────────────────────────────────────────────
|
|
103
122
|
|
|
104
123
|
export async function runOpStatus(ctx: CommandContext): Promise<number> {
|
|
124
|
+
if (!requireTemporalMode(ctx, "chant run status")) return 1;
|
|
105
125
|
const name = ctx.args.extraPositional;
|
|
106
126
|
if (!name) {
|
|
107
127
|
console.error(formatError({ message: "Op name is required: chant run status <name>" }));
|
|
@@ -141,6 +161,7 @@ export async function runOpStatus(ctx: CommandContext): Promise<number> {
|
|
|
141
161
|
// ── chant run log <name> ──────────────────────────────────────────────────────
|
|
142
162
|
|
|
143
163
|
export async function runOpLog(ctx: CommandContext): Promise<number> {
|
|
164
|
+
if (!requireTemporalMode(ctx, "chant run log")) return 1;
|
|
144
165
|
const name = ctx.args.extraPositional;
|
|
145
166
|
if (!name) {
|
|
146
167
|
console.error(formatError({ message: "Op name is required: chant run log <name>" }));
|
|
@@ -186,6 +207,7 @@ export async function runOpLog(ctx: CommandContext): Promise<number> {
|
|
|
186
207
|
// ── chant run signal <name> <signal> ─────────────────────────────────────────
|
|
187
208
|
|
|
188
209
|
export async function runOpSignal(ctx: CommandContext): Promise<number> {
|
|
210
|
+
if (!requireTemporalMode(ctx, "chant run signal")) return 1;
|
|
189
211
|
const name = ctx.args.extraPositional;
|
|
190
212
|
const signalName = ctx.args.extraPositional2;
|
|
191
213
|
|
|
@@ -212,6 +234,7 @@ export async function runOpSignal(ctx: CommandContext): Promise<number> {
|
|
|
212
234
|
// ── chant run cancel <name> ───────────────────────────────────────────────────
|
|
213
235
|
|
|
214
236
|
export async function runOpCancel(ctx: CommandContext): Promise<number> {
|
|
237
|
+
if (!requireTemporalMode(ctx, "chant run cancel")) return 1;
|
|
215
238
|
const name = ctx.args.extraPositional;
|
|
216
239
|
if (!name) {
|
|
217
240
|
console.error(formatError({ message: "Op name is required: chant run cancel <name>" }));
|
|
@@ -274,7 +297,107 @@ function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
|
|
|
274
297
|
);
|
|
275
298
|
}
|
|
276
299
|
|
|
300
|
+
/**
|
|
301
|
+
* `chant run <name>` dispatcher.
|
|
302
|
+
*
|
|
303
|
+
* Local mode is the default — it runs the Op in-process with no Temporal
|
|
304
|
+
* server. `--temporal` opts into a cluster (gates, schedules, durable resume).
|
|
305
|
+
* `--report` reads a past durable run and is therefore Temporal-only.
|
|
306
|
+
*/
|
|
277
307
|
export async function runOp(ctx: CommandContext): Promise<number> {
|
|
308
|
+
if (ctx.args.local && ctx.args.temporal) {
|
|
309
|
+
console.error(formatError({
|
|
310
|
+
message: "--local and --temporal are mutually exclusive",
|
|
311
|
+
hint: "omit both for local mode (the default), or pass exactly one",
|
|
312
|
+
}));
|
|
313
|
+
return 1;
|
|
314
|
+
}
|
|
315
|
+
if (ctx.args.report) {
|
|
316
|
+
if (!requireTemporalMode(ctx, "chant run --report")) return 1;
|
|
317
|
+
return runOpTemporal(ctx);
|
|
318
|
+
}
|
|
319
|
+
return ctx.args.temporal ? runOpTemporal(ctx) : runOpLocal(ctx);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Run an Op in-process via the local executor — no Temporal worker, server, or
|
|
324
|
+
* built `worker.ts`. Reads the Op config straight from discovery and resolves
|
|
325
|
+
* activities from the Temporal lexicon package.
|
|
326
|
+
*/
|
|
327
|
+
export async function runOpLocal(ctx: CommandContext): Promise<number> {
|
|
328
|
+
const opName = ctx.args.path;
|
|
329
|
+
if (!opName || opName === ".") {
|
|
330
|
+
console.error(formatError({
|
|
331
|
+
message: "Op name is required: chant run <name>",
|
|
332
|
+
hint: "Run `chant run list --temporal` to see available Ops",
|
|
333
|
+
}));
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const { ops, errors } = await discoverOps();
|
|
338
|
+
for (const err of errors) console.error(formatWarning({ message: err }));
|
|
339
|
+
|
|
340
|
+
const discovered = ops.get(opName);
|
|
341
|
+
if (!discovered) {
|
|
342
|
+
const names = [...ops.keys()];
|
|
343
|
+
console.error(formatError({
|
|
344
|
+
message: `Op "${opName}" not found`,
|
|
345
|
+
hint: names.length > 0
|
|
346
|
+
? `Available: ${names.join(", ")}`
|
|
347
|
+
: "No *.op.ts files found — create one",
|
|
348
|
+
}));
|
|
349
|
+
return 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const { config } = discovered;
|
|
353
|
+
|
|
354
|
+
// Pre-flight: gates/schedules need a durable runtime — fail before any step.
|
|
355
|
+
const gate = findGate(config);
|
|
356
|
+
if (gate) {
|
|
357
|
+
console.error(formatError({
|
|
358
|
+
message: new LocalGateUnsupportedError(gate.signalName).message,
|
|
359
|
+
}));
|
|
360
|
+
return 1;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let activities, profiles;
|
|
364
|
+
try {
|
|
365
|
+
[activities, profiles] = await Promise.all([loadActivities(), loadProfiles()]);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Ctrl-C aborts in-flight activities (kills their child processes) instead of
|
|
372
|
+
// orphaning them. The handler is removed in `finally` so it never leaks.
|
|
373
|
+
const controller = new AbortController();
|
|
374
|
+
const onSigint = () => {
|
|
375
|
+
console.error(formatWarning({ message: "interrupted — stopping Op" }));
|
|
376
|
+
controller.abort();
|
|
377
|
+
};
|
|
378
|
+
process.once("SIGINT", onSigint);
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const result = await runOpLocally(config, activities, profiles, controller.signal);
|
|
382
|
+
if (ctx.args.json) renderJson(result); else renderHuman(result);
|
|
383
|
+
return 0;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err instanceof OpRunFailure) {
|
|
386
|
+
if (ctx.args.json) renderJson(err.result); else renderHuman(err.result);
|
|
387
|
+
return 1;
|
|
388
|
+
}
|
|
389
|
+
if (err instanceof LocalGateUnsupportedError) {
|
|
390
|
+
console.error(formatError({ message: err.message }));
|
|
391
|
+
return 1;
|
|
392
|
+
}
|
|
393
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
394
|
+
return 1;
|
|
395
|
+
} finally {
|
|
396
|
+
process.removeListener("SIGINT", onSigint);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function runOpTemporal(ctx: CommandContext): Promise<number> {
|
|
278
401
|
const opName = ctx.args.path;
|
|
279
402
|
|
|
280
403
|
if (!opName || opName === ".") {
|
package/src/cli/main.test.ts
CHANGED
|
@@ -187,6 +187,8 @@ describe("resolveCommand", () => {
|
|
|
187
187
|
{ name: "init", handler: noop },
|
|
188
188
|
{ name: "init lexicon", handler: noop },
|
|
189
189
|
{ name: "dev", handler: noop },
|
|
190
|
+
{ name: "lifecycle plan", handler: noop },
|
|
191
|
+
{ name: "lifecycle", handler: noop },
|
|
190
192
|
];
|
|
191
193
|
|
|
192
194
|
function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
|
|
@@ -256,6 +258,18 @@ describe("resolveCommand", () => {
|
|
|
256
258
|
expect(result!.compound).toBe(true);
|
|
257
259
|
});
|
|
258
260
|
|
|
261
|
+
test("`lc` is an alias for `lifecycle` (compound)", () => {
|
|
262
|
+
const result = resolveCommand(makeArgs({ command: "lc", path: "plan" }), testRegistry);
|
|
263
|
+
expect(result!.def.name).toBe("lifecycle plan");
|
|
264
|
+
expect(result!.compound).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("`lc` is an alias for `lifecycle` (simple)", () => {
|
|
268
|
+
const result = resolveCommand(makeArgs({ command: "lc" }), testRegistry);
|
|
269
|
+
expect(result!.def.name).toBe("lifecycle");
|
|
270
|
+
expect(result!.compound).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
259
273
|
test("resolves run status as compound command", () => {
|
|
260
274
|
const registry: CommandDef[] = [
|
|
261
275
|
{ name: "run list", handler: noop },
|
package/src/cli/main.ts
CHANGED
|
@@ -13,7 +13,7 @@ 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 { runMigrate } from "./handlers/migrate";
|
|
16
|
-
import {
|
|
16
|
+
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecyclePlan, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
17
17
|
import { runGraph } from "./handlers/graph";
|
|
18
18
|
import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
|
|
19
19
|
|
|
@@ -37,6 +37,9 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
37
37
|
help: false,
|
|
38
38
|
profile: undefined,
|
|
39
39
|
report: undefined,
|
|
40
|
+
local: undefined,
|
|
41
|
+
temporal: undefined,
|
|
42
|
+
json: undefined,
|
|
40
43
|
live: false,
|
|
41
44
|
migrateFrom: undefined,
|
|
42
45
|
migrateTo: undefined,
|
|
@@ -46,6 +49,7 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
46
49
|
useComposites: false,
|
|
47
50
|
reportFile: undefined,
|
|
48
51
|
skill: undefined,
|
|
52
|
+
src: undefined,
|
|
49
53
|
};
|
|
50
54
|
|
|
51
55
|
let i = 0;
|
|
@@ -85,7 +89,17 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
85
89
|
} else if (arg === "--live") {
|
|
86
90
|
result.live = true;
|
|
87
91
|
} else if (arg === "--from") {
|
|
92
|
+
// Shared by `migrate --from <lexicon>` and `import --from <env>`; the
|
|
93
|
+
// two commands never run together, so one field carries both.
|
|
88
94
|
result.migrateFrom = args[++i];
|
|
95
|
+
} else if (arg === "--type") {
|
|
96
|
+
result.selectType = args[++i];
|
|
97
|
+
} else if (arg === "--name") {
|
|
98
|
+
result.selectName = args[++i];
|
|
99
|
+
} else if (arg === "--owned") {
|
|
100
|
+
result.owned = true;
|
|
101
|
+
} else if (arg === "--verbatim") {
|
|
102
|
+
result.verbatim = true;
|
|
89
103
|
} else if (arg === "--to") {
|
|
90
104
|
result.migrateTo = args[++i];
|
|
91
105
|
} else if (arg === "--emit") {
|
|
@@ -98,6 +112,14 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
98
112
|
result.useComposites = true;
|
|
99
113
|
} else if (arg === "--skill") {
|
|
100
114
|
result.skill = args[++i];
|
|
115
|
+
} else if (arg === "--src") {
|
|
116
|
+
result.src = args[++i];
|
|
117
|
+
} else if (arg === "--local") {
|
|
118
|
+
result.local = true;
|
|
119
|
+
} else if (arg === "--temporal") {
|
|
120
|
+
result.temporal = true;
|
|
121
|
+
} else if (arg === "--json") {
|
|
122
|
+
result.json = true;
|
|
101
123
|
} else if (!arg.startsWith("-")) {
|
|
102
124
|
if (!result.command) {
|
|
103
125
|
result.command = arg;
|
|
@@ -146,12 +168,14 @@ Ops:
|
|
|
146
168
|
|
|
147
169
|
graph Show Op dependency graph
|
|
148
170
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
171
|
+
Lifecycle (alias: lc):
|
|
172
|
+
lifecycle snapshot <env> Query API, save metadata to orphan branch
|
|
173
|
+
lifecycle show <env> Show latest lifecycle snapshot
|
|
174
|
+
lifecycle diff <env> Compare current build against last snapshot
|
|
175
|
+
--live: query cloud now and detect drift
|
|
176
|
+
lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
|
|
177
|
+
--json: emit the ChangeSet as JSON
|
|
178
|
+
lifecycle log [env] History of lifecycle snapshots
|
|
155
179
|
|
|
156
180
|
Lexicon development:
|
|
157
181
|
dev generate Generate lexicon artifacts (+ validate + coverage)
|
|
@@ -182,6 +206,9 @@ Options:
|
|
|
182
206
|
-v, --verbose Show stack traces on errors
|
|
183
207
|
-h, --help Show this help message
|
|
184
208
|
-p, --profile <name> Temporal worker profile to use (run command)
|
|
209
|
+
--local Run an Op with the local in-process executor (default)
|
|
210
|
+
--temporal Run an Op via a Temporal cluster (gates, schedules, durable resume)
|
|
211
|
+
--json Emit the structured run result as JSON (run command)
|
|
185
212
|
--report Print deployment report instead of running (run command)
|
|
186
213
|
OR with a path arg: SARIF report destination (migrate)
|
|
187
214
|
--from <name> Source lexicon for migrate (default: github)
|
|
@@ -197,6 +224,7 @@ Examples:
|
|
|
197
224
|
chant build ./infra/ --format yaml
|
|
198
225
|
chant build ./infra/ --watch
|
|
199
226
|
chant import template.json --output ./infra/
|
|
227
|
+
chant import --from prod --name my-bucket --output src/
|
|
200
228
|
chant lint ./infra/
|
|
201
229
|
chant lint ./infra/ --format sarif
|
|
202
230
|
chant lint ./infra/ --watch
|
|
@@ -261,17 +289,18 @@ const registry: CommandDef[] = [
|
|
|
261
289
|
{ name: "graph", handler: runGraph },
|
|
262
290
|
|
|
263
291
|
// State subcommands
|
|
264
|
-
{ name: "
|
|
265
|
-
{ name: "
|
|
266
|
-
{ name: "
|
|
267
|
-
{ name: "
|
|
292
|
+
{ name: "lifecycle snapshot", requiresPlugins: true, handler: runLifecycleSnapshot },
|
|
293
|
+
{ name: "lifecycle show", handler: runLifecycleShow },
|
|
294
|
+
{ name: "lifecycle diff", requiresPlugins: true, handler: runLifecycleDiff },
|
|
295
|
+
{ name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
|
|
296
|
+
{ name: "lifecycle log", handler: runLifecycleLog },
|
|
268
297
|
|
|
269
298
|
// Serve subcommands
|
|
270
299
|
{ name: "serve lsp", requiresPlugins: true, handler: runServeLsp },
|
|
271
300
|
{ name: "serve mcp", requiresPlugins: true, handler: runServeMcp },
|
|
272
301
|
|
|
273
302
|
// Fallback for unknown subcommands (must come after compound entries)
|
|
274
|
-
{ name: "
|
|
303
|
+
{ name: "lifecycle", handler: runLifecycleUnknown },
|
|
275
304
|
{ name: "dev", handler: runDevUnknown },
|
|
276
305
|
{ name: "serve", handler: runServeUnknown },
|
|
277
306
|
];
|
|
@@ -306,9 +335,14 @@ async function main(): Promise<void> {
|
|
|
306
335
|
process.exit(1);
|
|
307
336
|
}
|
|
308
337
|
|
|
309
|
-
// For compound commands (e.g. "run list"
|
|
310
|
-
//
|
|
311
|
-
|
|
338
|
+
// For compound commands (e.g. "run list", "lifecycle plan <env>"), the first
|
|
339
|
+
// positional is a subcommand argument — an environment, op, or lexicon name —
|
|
340
|
+
// not a project path. Plugins always load from the cwd; the handler reads its
|
|
341
|
+
// own positionals from args.extraPositional. Using extraPositional as the path
|
|
342
|
+
// here pointed plugin resolution at e.g. "./local" for `lifecycle plan local`,
|
|
343
|
+
// which then fell through to import-detection on an empty file set and failed
|
|
344
|
+
// with "No lexicon detected" even though chant.config.ts lists the lexicons.
|
|
345
|
+
const projectPath = match.compound ? "." : args.path;
|
|
312
346
|
const plugins = match.def.requiresPlugins
|
|
313
347
|
? await loadPluginsOrExit(projectPath)
|
|
314
348
|
: [];
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import type { LexiconPlugin } from "../../lexicon";
|
|
3
3
|
import type { ToolDefinition, ToolHandler } from "./types";
|
|
4
|
-
import { readSnapshot } from "../../
|
|
4
|
+
import { readSnapshot } from "../../lifecycle/git";
|
|
5
5
|
import { build } from "../../build";
|
|
6
|
-
import { computeBuildDigest, diffDigests } from "../../
|
|
7
|
-
import { takeSnapshot } from "../../
|
|
8
|
-
import type {
|
|
6
|
+
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
7
|
+
import { takeSnapshot } from "../../lifecycle/snapshot";
|
|
8
|
+
import type { LifecycleSnapshot } from "../../lifecycle/types";
|
|
9
9
|
export interface ToolRegistration {
|
|
10
10
|
definition: ToolDefinition;
|
|
11
11
|
handler: ToolHandler;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Create
|
|
15
|
+
* Create lifecycle-snapshot tool definition and handler
|
|
16
16
|
*/
|
|
17
17
|
export function createSnapshotTool(plugins: LexiconPlugin[]): ToolRegistration {
|
|
18
18
|
return {
|
|
19
19
|
definition: {
|
|
20
|
-
name: "
|
|
20
|
+
name: "lifecycle-snapshot",
|
|
21
21
|
description: "Capture deployed state for an environment",
|
|
22
22
|
inputSchema: {
|
|
23
23
|
type: "object",
|
|
@@ -46,12 +46,12 @@ export function createSnapshotTool(plugins: LexiconPlugin[]): ToolRegistration {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Create
|
|
49
|
+
* Create lifecycle-diff tool definition and handler
|
|
50
50
|
*/
|
|
51
51
|
export function createDiffTool(plugins: LexiconPlugin[]): ToolRegistration {
|
|
52
52
|
return {
|
|
53
53
|
definition: {
|
|
54
|
-
name: "
|
|
54
|
+
name: "lifecycle-diff",
|
|
55
55
|
description: "Compare current build declarations against last snapshot's digest",
|
|
56
56
|
inputSchema: {
|
|
57
57
|
type: "object",
|
|
@@ -75,7 +75,7 @@ export function createDiffTool(plugins: LexiconPlugin[]): ToolRegistration {
|
|
|
75
75
|
const content = await readSnapshot(env, lex);
|
|
76
76
|
let previousDigest = undefined;
|
|
77
77
|
if (content) {
|
|
78
|
-
const snapshot:
|
|
78
|
+
const snapshot: LifecycleSnapshot = JSON.parse(content);
|
|
79
79
|
previousDigest = snapshot.digest;
|
|
80
80
|
}
|
|
81
81
|
results[lex] = diffDigests(currentDigest, previousDigest);
|
package/src/cli/mcp/op-tools.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { discoverOps } from "../../op/discover";
|
|
|
3
3
|
import { makeTemporalClient } from "../handlers/run";
|
|
4
4
|
import { resolveWorkflowId } from "../handlers/run-client";
|
|
5
5
|
import { generateReport } from "../handlers/run-report";
|
|
6
|
-
import type { ToolRegistration } from "./
|
|
6
|
+
import type { ToolRegistration } from "./lifecycle-tools";
|
|
7
7
|
|
|
8
8
|
function workflowFnName(opName: string): string {
|
|
9
9
|
return opName.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()) + "Workflow";
|
|
@@ -63,7 +63,7 @@ export function createOpRunTool(): ToolRegistration {
|
|
|
63
63
|
definition: {
|
|
64
64
|
name: "op-run",
|
|
65
65
|
description:
|
|
66
|
-
"Submit an Op workflow to Temporal.
|
|
66
|
+
"Submit an Op workflow to Temporal (Temporal mode). A Temporal worker must already be running — start it with `chant run <name> --temporal`.",
|
|
67
67
|
inputSchema: {
|
|
68
68
|
type: "object",
|
|
69
69
|
properties: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import type { ResourceDefinition } from "./types";
|
|
3
3
|
import { getContext } from "./resources/context";
|
|
4
|
-
import { readSnapshot, readEnvironmentSnapshots } from "../../
|
|
4
|
+
import { readSnapshot, readEnvironmentSnapshots } from "../../lifecycle/git";
|
|
5
5
|
import { discoverOps } from "../../op/discover";
|
|
6
6
|
import { makeTemporalClient } from "../handlers/run";
|
|
7
7
|
import { resolveWorkflowId } from "../handlers/run-client";
|
|
@@ -1133,9 +1133,9 @@ describe("McpServer", () => {
|
|
|
1133
1133
|
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
|
1134
1134
|
expect(tools).toHaveLength(13);
|
|
1135
1135
|
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
1136
|
-
"build", "explain", "import", "lint",
|
|
1136
|
+
"build", "explain", "import", "lifecycle-diff", "lifecycle-snapshot", "lint",
|
|
1137
1137
|
"op-list", "op-report", "op-run", "op-signal", "op-status",
|
|
1138
|
-
"scaffold", "search",
|
|
1138
|
+
"scaffold", "search",
|
|
1139
1139
|
]);
|
|
1140
1140
|
|
|
1141
1141
|
const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
|
|
|
8
8
|
import { searchTool, createSearchHandler } from "./tools/search";
|
|
9
9
|
import type { LexiconPlugin } from "../../lexicon";
|
|
10
10
|
import type { McpRequest, McpResponse, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
|
|
11
|
-
import { createSnapshotTool, createDiffTool } from "./
|
|
11
|
+
import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
|
|
12
12
|
import { createOpListTool, createOpRunTool, createOpStatusTool, createOpSignalTool, createOpReportTool } from "./op-tools";
|
|
13
13
|
import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
|
|
14
14
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface ParsedArgs {
|
|
|
20
20
|
help: boolean;
|
|
21
21
|
profile?: string;
|
|
22
22
|
report?: boolean;
|
|
23
|
+
/** `chant run` — force the local in-process executor (the default). */
|
|
24
|
+
local?: boolean;
|
|
25
|
+
/** `chant run` — run via a Temporal cluster instead of the local executor. */
|
|
26
|
+
temporal?: boolean;
|
|
27
|
+
/** `chant run` — emit the structured OpRunResult as JSON on stdout. */
|
|
28
|
+
json?: boolean;
|
|
23
29
|
live: boolean;
|
|
24
30
|
/** `chant migrate --from <name>` (default "github") */
|
|
25
31
|
migrateFrom?: string;
|
|
@@ -37,6 +43,16 @@ export interface ParsedArgs {
|
|
|
37
43
|
reportFile?: string;
|
|
38
44
|
/** `chant init --skill <name>` filter (added in #95 commit) */
|
|
39
45
|
skill?: string;
|
|
46
|
+
/** `chant import --type <ResourceType>` selector */
|
|
47
|
+
selectType?: string;
|
|
48
|
+
/** `chant import --name <name>` selector */
|
|
49
|
+
selectName?: string;
|
|
50
|
+
/** `chant import --owned` — restrict live import to chant-owned resources */
|
|
51
|
+
owned?: boolean;
|
|
52
|
+
/** `chant import --verbatim` — keep server-defaulted fields in live import */
|
|
53
|
+
verbatim?: boolean;
|
|
54
|
+
/** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
|
|
55
|
+
src?: string;
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
/**
|
|
@@ -75,16 +91,21 @@ export interface ResolvedCommand {
|
|
|
75
91
|
* Supports compound commands like "dev generate" where args.command="dev"
|
|
76
92
|
* and args.path="generate". Falls back to simple command matching.
|
|
77
93
|
*/
|
|
94
|
+
/** Short command aliases. `chant lc …` is sugar for `chant lifecycle …`. */
|
|
95
|
+
const COMMAND_ALIASES: Record<string, string> = { lc: "lifecycle" };
|
|
96
|
+
|
|
78
97
|
export function resolveCommand(args: ParsedArgs, registry: CommandDef[]): ResolvedCommand | null {
|
|
98
|
+
const command = COMMAND_ALIASES[args.command] ?? args.command;
|
|
99
|
+
|
|
79
100
|
// Try compound command first: "dev generate", "serve lsp", "init lexicon"
|
|
80
|
-
const compound = `${
|
|
101
|
+
const compound = `${command} ${args.path}`;
|
|
81
102
|
const compoundMatch = registry.find((c) => c.name === compound);
|
|
82
103
|
if (compoundMatch) {
|
|
83
104
|
return { def: compoundMatch, compound: true };
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
// Try simple command
|
|
87
|
-
const simpleMatch = registry.find((c) => c.name ===
|
|
108
|
+
const simpleMatch = registry.find((c) => c.name === command);
|
|
88
109
|
if (simpleMatch) {
|
|
89
110
|
return { def: simpleMatch, compound: false };
|
|
90
111
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { extractFromTar } from "./fetch";
|
|
1
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { extractFromTar, fetchWithRetry } from "./fetch";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Build a minimal valid tar buffer with a single file entry.
|
|
@@ -117,3 +117,104 @@ describe("extractFromTar", () => {
|
|
|
117
117
|
expect(files.get("multi.txt")!.toString()).toBe(content);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("fetchWithRetry", () => {
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
vi.restoreAllMocks();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const ok = () => new Response("payload", { status: 200 });
|
|
127
|
+
const status = (code: number) => new Response("", { status: code });
|
|
128
|
+
|
|
129
|
+
test("returns immediately on a successful response", async () => {
|
|
130
|
+
const fetchMock = vi.fn().mockResolvedValue(ok());
|
|
131
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
132
|
+
|
|
133
|
+
const resp = await fetchWithRetry("https://example.test/x", 4, 1);
|
|
134
|
+
expect(resp.ok).toBe(true);
|
|
135
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("retries a transient status then succeeds", async () => {
|
|
139
|
+
const fetchMock = vi
|
|
140
|
+
.fn()
|
|
141
|
+
.mockResolvedValueOnce(status(504))
|
|
142
|
+
.mockResolvedValueOnce(status(503))
|
|
143
|
+
.mockResolvedValueOnce(ok());
|
|
144
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
145
|
+
|
|
146
|
+
const resp = await fetchWithRetry("https://example.test/x", 4, 1);
|
|
147
|
+
expect(resp.ok).toBe(true);
|
|
148
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("retries a network error then succeeds", async () => {
|
|
152
|
+
const fetchMock = vi
|
|
153
|
+
.fn()
|
|
154
|
+
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
|
155
|
+
.mockResolvedValueOnce(ok());
|
|
156
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
157
|
+
|
|
158
|
+
const resp = await fetchWithRetry("https://example.test/x", 4, 1);
|
|
159
|
+
expect(resp.ok).toBe(true);
|
|
160
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("does not retry a permanent status", async () => {
|
|
164
|
+
const fetchMock = vi.fn().mockResolvedValue(status(404));
|
|
165
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
166
|
+
|
|
167
|
+
await expect(fetchWithRetry("https://example.test/x", 4, 1)).rejects.toThrow("returned 404");
|
|
168
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("throws after exhausting retries on a transient status", async () => {
|
|
172
|
+
const fetchMock = vi.fn().mockResolvedValue(status(504));
|
|
173
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
174
|
+
|
|
175
|
+
await expect(fetchWithRetry("https://example.test/x", 2, 1)).rejects.toThrow("returned 504");
|
|
176
|
+
// initial attempt + 2 retries
|
|
177
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("calls fetch with no init argument when none is given", async () => {
|
|
181
|
+
const fetchMock = vi.fn().mockResolvedValue(ok());
|
|
182
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
183
|
+
|
|
184
|
+
await fetchWithRetry("https://example.test/x", 4, 1);
|
|
185
|
+
expect(fetchMock).toHaveBeenCalledWith("https://example.test/x");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("passes request init through to fetch", async () => {
|
|
189
|
+
const fetchMock = vi.fn().mockResolvedValue(ok());
|
|
190
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
191
|
+
|
|
192
|
+
const init = { headers: { Accept: "application/vnd.github+json" } };
|
|
193
|
+
await fetchWithRetry("https://example.test/x", 4, 1, init);
|
|
194
|
+
expect(fetchMock).toHaveBeenCalledWith("https://example.test/x", init);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("preserves init across retries on a transient status", async () => {
|
|
198
|
+
const fetchMock = vi
|
|
199
|
+
.fn()
|
|
200
|
+
.mockResolvedValueOnce(status(503))
|
|
201
|
+
.mockResolvedValueOnce(ok());
|
|
202
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
203
|
+
|
|
204
|
+
const init = { headers: { Accept: "application/vnd.github+json" } };
|
|
205
|
+
const resp = await fetchWithRetry("https://example.test/x", 4, 1, init);
|
|
206
|
+
expect(resp.ok).toBe(true);
|
|
207
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
208
|
+
expect(fetchMock).toHaveBeenNthCalledWith(1, "https://example.test/x", init);
|
|
209
|
+
expect(fetchMock).toHaveBeenNthCalledWith(2, "https://example.test/x", init);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("does not retry a permanent status when init is given", async () => {
|
|
213
|
+
const fetchMock = vi.fn().mockResolvedValue(status(403));
|
|
214
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
215
|
+
|
|
216
|
+
const init = { headers: { Accept: "application/vnd.github+json" } };
|
|
217
|
+
await expect(fetchWithRetry("https://example.test/x", 4, 1, init)).rejects.toThrow("returned 403");
|
|
218
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
219
|
+
});
|
|
220
|
+
});
|