@aicopycoders/exodus 2026.7.200-beta.4 → 2026.7.200-beta.6
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/assets/skills/exodus/SKILL.md +1 -0
- package/assets/skills/exodus-workflow/SKILL.md +173 -0
- package/dist/bin/exodus.js +1 -0
- package/dist/commands/brand.js +2 -1
- package/dist/commands/doctor.js +22 -16
- package/dist/commands/init.js +3 -2
- package/dist/commands/meme.js +5 -4
- package/dist/commands/migrate.js +7 -6
- package/dist/commands/workflow.d.ts +271 -0
- package/dist/commands/workflow.js +744 -0
- package/dist/lib/channel.d.ts +5 -0
- package/dist/lib/channel.js +15 -0
- package/dist/lib/scaffold.d.ts +5 -4
- package/dist/lib/scaffold.js +27 -6
- package/package.json +1 -1
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { apiGet, apiPost } from "../lib/client.js";
|
|
3
|
+
import { formatError } from "../lib/format.js";
|
|
4
|
+
import { pollUntilDone } from "../lib/poll.js";
|
|
5
|
+
export const helpText = `
|
|
6
|
+
exodus workflow — List, describe, run, inspect, import, and export saved workflows
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
exodus workflow list [--json]
|
|
10
|
+
exodus workflow describe <workflowId|name> [--json]
|
|
11
|
+
exodus workflow bots [--category <cat>] [--slug <slug>] [--json]
|
|
12
|
+
exodus workflow run <workflowId|name> [--input key=value ...] [--wait] [--json]
|
|
13
|
+
exodus workflow status [--id <runId>] [--json]
|
|
14
|
+
exodus workflow export <workflowId|name> [--out <file>]
|
|
15
|
+
exodus workflow import <file> [--update <workflowId>] [--dry-run] [--json]
|
|
16
|
+
|
|
17
|
+
Flags:
|
|
18
|
+
--json Machine-readable JSON output
|
|
19
|
+
--category <cat> (bots) Filter the catalog to one category id
|
|
20
|
+
--slug <slug> (bots) Show a single bot's full port + param spec
|
|
21
|
+
--input key=value Repeatable workflow run input; values may contain "="
|
|
22
|
+
--input key=@path loads the value from a file (path is
|
|
23
|
+
resolved from the current directory); --input key=@@text
|
|
24
|
+
keeps a leading "@" as a literal character.
|
|
25
|
+
--wait Poll until the workflow run reaches a terminal status
|
|
26
|
+
--id <runId> Workflow run id for status detail
|
|
27
|
+
--out <file> Write export JSON to a file instead of stdout
|
|
28
|
+
--update <workflowId> (import) Update this existing workflow in place instead
|
|
29
|
+
of creating a new one. Sends the contract's updatedAt as
|
|
30
|
+
an optimistic-concurrency guard — if it 409s, re-export
|
|
31
|
+
the workflow, reapply edits, and import again.
|
|
32
|
+
--dry-run Preview import (validate + resolve refs) without writing
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
exodus workflow describe "Launch Flow"
|
|
36
|
+
exodus workflow bots
|
|
37
|
+
exodus workflow bots --category writing
|
|
38
|
+
exodus workflow bots --slug new-hook-bot
|
|
39
|
+
exodus workflow run "Launch Flow" --input brief="new offer" --wait
|
|
40
|
+
exodus workflow run "Launch Flow" --input brief=@brief.txt
|
|
41
|
+
exodus workflow status --id wr_123
|
|
42
|
+
exodus workflow export "Launch Flow" --out workflow.json
|
|
43
|
+
exodus workflow import workflow.json --dry-run
|
|
44
|
+
exodus workflow import workflow.json --update wf_123
|
|
45
|
+
|
|
46
|
+
Notes:
|
|
47
|
+
workflow bots --json emits the FULL catalog response verbatim (--category /
|
|
48
|
+
--slug filters are ignored in that mode). workflow bots --slug <slug> --json
|
|
49
|
+
emits just that one bot's catalog JSON.
|
|
50
|
+
`.trim();
|
|
51
|
+
const LIST_PATH = "/api/v2/workflows";
|
|
52
|
+
const RUN_PATH = "/api/v2/workflows/run";
|
|
53
|
+
const STATUS_PATH = "/api/v2/workflow";
|
|
54
|
+
const EXPORT_PATH = "/api/v2/workflows/export";
|
|
55
|
+
const IMPORT_PATH = "/api/v2/workflows/import";
|
|
56
|
+
const DESCRIBE_PATH = "/api/v2/workflows/describe";
|
|
57
|
+
const CATALOG_PATH = "/api/v2/workflows/catalog";
|
|
58
|
+
const VALUE_FLAGS = new Set(["id", "input", "out", "category", "slug", "update"]);
|
|
59
|
+
const defaultDeps = {
|
|
60
|
+
get: (path) => apiGet(path),
|
|
61
|
+
post: (path, body) => apiPost(path, body),
|
|
62
|
+
readFile: (path) => fs.readFileSync(path, "utf-8"),
|
|
63
|
+
writeFile: (path, text) => fs.writeFileSync(path, text, "utf-8"),
|
|
64
|
+
poll: (opts) => pollUntilDone(opts),
|
|
65
|
+
};
|
|
66
|
+
function asErrorResult(res, json) {
|
|
67
|
+
return {
|
|
68
|
+
code: 1,
|
|
69
|
+
lines: json
|
|
70
|
+
? [JSON.stringify({ ok: false, status: res.status, data: res.data })]
|
|
71
|
+
: [formatError(res)],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function isRecord(value) {
|
|
75
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
76
|
+
}
|
|
77
|
+
function dateOnly(value) {
|
|
78
|
+
if (!value)
|
|
79
|
+
return "-";
|
|
80
|
+
const parsed = new Date(value);
|
|
81
|
+
if (Number.isNaN(parsed.getTime()))
|
|
82
|
+
return String(value);
|
|
83
|
+
return parsed.toISOString().slice(0, 10);
|
|
84
|
+
}
|
|
85
|
+
function table(headers, rows) {
|
|
86
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
|
|
87
|
+
const fmt = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
|
|
88
|
+
return [fmt(headers), fmt(headers.map((h) => "-".repeat(h.length))), ...rows.map(fmt)].join("\n");
|
|
89
|
+
}
|
|
90
|
+
function statusIcon(status) {
|
|
91
|
+
if (status === "completed" || status === "done")
|
|
92
|
+
return "✓";
|
|
93
|
+
if (status === "failed")
|
|
94
|
+
return "✗";
|
|
95
|
+
if (status === "skipped")
|
|
96
|
+
return "-";
|
|
97
|
+
return "…";
|
|
98
|
+
}
|
|
99
|
+
function truncateText(text, max = 200) {
|
|
100
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
101
|
+
if (normalized.length <= max)
|
|
102
|
+
return normalized;
|
|
103
|
+
return `${normalized.slice(0, max - 3).trimEnd()}...`;
|
|
104
|
+
}
|
|
105
|
+
function formatCounts(counts) {
|
|
106
|
+
if (!counts)
|
|
107
|
+
return "";
|
|
108
|
+
return `done=${counts.done}/${counts.total}, failed=${counts.failed}, skipped=${counts.skipped}`;
|
|
109
|
+
}
|
|
110
|
+
function outputLines(output) {
|
|
111
|
+
if (output.type === "text")
|
|
112
|
+
return [` text: ${truncateText(output.text)}`];
|
|
113
|
+
if (output.type === "primer") {
|
|
114
|
+
return [` primer:${output.primerKind}: ${truncateText(output.text)}`];
|
|
115
|
+
}
|
|
116
|
+
return [` image: ${output.imageUrl ?? output.storageId}`];
|
|
117
|
+
}
|
|
118
|
+
function progressLine(node) {
|
|
119
|
+
const err = node.error ? ` — error: ${node.error}` : "";
|
|
120
|
+
return ` ${statusIcon(node.status)} ${node.nodeId} (${node.kind}) ${node.status}${err}`;
|
|
121
|
+
}
|
|
122
|
+
function expandInputValue(key, value, readFile) {
|
|
123
|
+
if (value.startsWith("@@"))
|
|
124
|
+
return value.slice(1);
|
|
125
|
+
if (value.startsWith("@")) {
|
|
126
|
+
const filePath = value.slice(1);
|
|
127
|
+
if (!filePath) {
|
|
128
|
+
throw new Error(`--input ${key}=@<file> needs a file path after "@"`);
|
|
129
|
+
}
|
|
130
|
+
if (!readFile) {
|
|
131
|
+
throw new Error(`--input ${key}: cannot load @${filePath} here (no file access)`);
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return readFile(filePath);
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
138
|
+
throw new Error(`--input ${key}: could not read file "${filePath}": ${msg}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
export function parseInputFlags(args, readFile) {
|
|
144
|
+
const inputs = {};
|
|
145
|
+
for (let i = 0; i < args.length; i++) {
|
|
146
|
+
const arg = args[i];
|
|
147
|
+
let raw;
|
|
148
|
+
if (arg === "--input") {
|
|
149
|
+
raw = args[i + 1];
|
|
150
|
+
i++;
|
|
151
|
+
}
|
|
152
|
+
else if (arg.startsWith("--input=")) {
|
|
153
|
+
raw = arg.slice("--input=".length);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!raw)
|
|
159
|
+
throw new Error("--input requires key=value");
|
|
160
|
+
const eq = raw.indexOf("=");
|
|
161
|
+
if (eq <= 0)
|
|
162
|
+
throw new Error(`--input must be key=value (got "${raw}")`);
|
|
163
|
+
const key = raw.slice(0, eq).trim();
|
|
164
|
+
if (!key)
|
|
165
|
+
throw new Error(`--input must include a key (got "${raw}")`);
|
|
166
|
+
inputs[key] = expandInputValue(key, raw.slice(eq + 1), readFile);
|
|
167
|
+
}
|
|
168
|
+
return inputs;
|
|
169
|
+
}
|
|
170
|
+
export function formatWorkflowList(workflows) {
|
|
171
|
+
if (workflows.length === 0)
|
|
172
|
+
return "No workflows found for the active brand.";
|
|
173
|
+
return table(["name", "nodes", "edges", "updated", "id"], workflows.map((w) => [
|
|
174
|
+
w.name,
|
|
175
|
+
String(w.nodeCount),
|
|
176
|
+
String(w.edgeCount),
|
|
177
|
+
dateOnly(w.updatedAt),
|
|
178
|
+
w._id,
|
|
179
|
+
]));
|
|
180
|
+
}
|
|
181
|
+
export function formatRecentRuns(runs) {
|
|
182
|
+
if (runs.length === 0)
|
|
183
|
+
return "No workflow runs found for the active brand.";
|
|
184
|
+
return table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, r.status, dateOnly(r.createdAt), r._id]));
|
|
185
|
+
}
|
|
186
|
+
export function formatImportSummary(result, mode = {}) {
|
|
187
|
+
const lines = [];
|
|
188
|
+
const heading = mode.dryRun
|
|
189
|
+
? "Workflow import preview:"
|
|
190
|
+
: result.created
|
|
191
|
+
? "Workflow imported."
|
|
192
|
+
: mode.update
|
|
193
|
+
? "Workflow updated."
|
|
194
|
+
: "Workflow import preview:";
|
|
195
|
+
lines.push(heading);
|
|
196
|
+
lines.push(`name: ${result.name}`);
|
|
197
|
+
if (result.workflowId)
|
|
198
|
+
lines.push(`workflowId: ${result.workflowId}`);
|
|
199
|
+
lines.push(`nodes: ${result.nodeCount}`);
|
|
200
|
+
lines.push(`edges: ${result.edgeCount}`);
|
|
201
|
+
if (result.unresolved.length > 0) {
|
|
202
|
+
lines.push("");
|
|
203
|
+
lines.push(`Unresolved references (${result.unresolved.length}):`);
|
|
204
|
+
for (const ref of result.unresolved) {
|
|
205
|
+
lines.push(` ${ref.nodeId.padEnd(20)} ${ref.ref.padEnd(7)} ${ref.value} — ${ref.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (result.warnings.length > 0) {
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push(`Warnings (${result.warnings.length}):`);
|
|
211
|
+
for (const warning of result.warnings)
|
|
212
|
+
lines.push(` ${warning}`);
|
|
213
|
+
}
|
|
214
|
+
return lines.join("\n");
|
|
215
|
+
}
|
|
216
|
+
export function formatWorkflowRun(run) {
|
|
217
|
+
const lines = [];
|
|
218
|
+
const counts = formatCounts(run.counts);
|
|
219
|
+
lines.push(`Workflow run — ${run.workflowName}`);
|
|
220
|
+
lines.push(`runId: ${run._id}`);
|
|
221
|
+
lines.push(`workflowId: ${run.workflowId}`);
|
|
222
|
+
if (run.triggerRunId)
|
|
223
|
+
lines.push(`triggerRunId: ${run.triggerRunId}`);
|
|
224
|
+
lines.push(`verdict: ${run.status}${counts ? ` (${counts})` : ""}`);
|
|
225
|
+
if (run.isTerminal)
|
|
226
|
+
lines.push("terminal: yes");
|
|
227
|
+
if (run.error)
|
|
228
|
+
lines.push(`error: ${run.error}`);
|
|
229
|
+
if (Object.keys(run.inputs ?? {}).length > 0) {
|
|
230
|
+
const inputs = Object.entries(run.inputs).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
231
|
+
lines.push(`inputs: ${inputs}`);
|
|
232
|
+
}
|
|
233
|
+
if (run.nodes.length > 0) {
|
|
234
|
+
lines.push("");
|
|
235
|
+
lines.push(`Nodes (${run.nodes.length}):`);
|
|
236
|
+
for (const node of run.nodes) {
|
|
237
|
+
lines.push(progressLine(node));
|
|
238
|
+
for (const output of node.outputs)
|
|
239
|
+
lines.push(...outputLines(output));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (run.outputs && run.outputs.length > 0) {
|
|
243
|
+
lines.push("");
|
|
244
|
+
lines.push(`Outputs (${run.outputs.length}):`);
|
|
245
|
+
for (const output of run.outputs)
|
|
246
|
+
lines.push(...runOutputLines(output));
|
|
247
|
+
}
|
|
248
|
+
return lines.join("\n");
|
|
249
|
+
}
|
|
250
|
+
function runOutputLines(output) {
|
|
251
|
+
const slug = output.botSlug ? ` (${output.botSlug})` : "";
|
|
252
|
+
if (output.type === "image") {
|
|
253
|
+
return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}`];
|
|
254
|
+
}
|
|
255
|
+
const raw = output.text ?? "";
|
|
256
|
+
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
257
|
+
const body = truncateText(raw, 400);
|
|
258
|
+
const note = normalized.length > 400 ? "\n (truncated — use --json for the full text)" : "";
|
|
259
|
+
return [` ${output.label} [text]${slug}:`, ` ${body}${note}`];
|
|
260
|
+
}
|
|
261
|
+
export function formatDescribe(res) {
|
|
262
|
+
const lines = [];
|
|
263
|
+
lines.push(`Workflow — ${res.name}`);
|
|
264
|
+
lines.push(`workflowId: ${res.workflowId}`);
|
|
265
|
+
if (res.description)
|
|
266
|
+
lines.push(`description: ${res.description}`);
|
|
267
|
+
lines.push(`updated: ${dateOnly(res.updatedAt)}`);
|
|
268
|
+
lines.push("");
|
|
269
|
+
lines.push(`Inputs (${res.inputs.length}):`);
|
|
270
|
+
if (res.inputs.length === 0) {
|
|
271
|
+
lines.push(" (none — this workflow takes no run inputs)");
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
for (const input of res.inputs) {
|
|
275
|
+
const req = input.required ? "required" : "optional";
|
|
276
|
+
const bundle = input.bundleSize !== undefined ? `, bundle=${input.bundleSize}` : "";
|
|
277
|
+
lines.push(` ${input.fieldName} — ${input.source}, ${req}${bundle}`);
|
|
278
|
+
if (input.description)
|
|
279
|
+
lines.push(` ${input.description}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
lines.push("");
|
|
283
|
+
lines.push(`Prerequisites (${res.prerequisites.length}):`);
|
|
284
|
+
if (res.prerequisites.length === 0) {
|
|
285
|
+
lines.push(" (none — no stored primers required)");
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
for (const prereq of res.prerequisites) {
|
|
289
|
+
const mark = prereq.stored ? "✓ stored" : "✗ MISSING";
|
|
290
|
+
lines.push(` ${mark} ${prereq.primerKind} primer (nodes: ${prereq.nodeIds.join(", ")})`);
|
|
291
|
+
}
|
|
292
|
+
const missing = res.prerequisites.filter((p) => !p.stored);
|
|
293
|
+
if (missing.length > 0) {
|
|
294
|
+
lines.push("");
|
|
295
|
+
lines.push(` ✗ ${missing.length} primer(s) not stored for this brand — add ${missing
|
|
296
|
+
.map((p) => `"${p.primerKind}"`)
|
|
297
|
+
.join(", ")} before running, or those nodes fail.`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
lines.push("");
|
|
301
|
+
lines.push(`Outputs (${res.outputs.length}):`);
|
|
302
|
+
if (res.outputs.length === 0) {
|
|
303
|
+
lines.push(" (none wired into an Output node)");
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
for (const output of res.outputs) {
|
|
307
|
+
const slug = output.botSlug ? ` (${output.botSlug})` : "";
|
|
308
|
+
lines.push(` ${output.label} [${output.type}]${slug}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return lines.join("\n");
|
|
312
|
+
}
|
|
313
|
+
function formatBotVocabulary(catalog) {
|
|
314
|
+
const v = catalog.vocabulary;
|
|
315
|
+
const lines = [];
|
|
316
|
+
lines.push("Vocabulary:");
|
|
317
|
+
lines.push(` node kinds: ${v.nodeKinds.join(", ")}`);
|
|
318
|
+
lines.push(` brief sources: ${v.briefSources.join(", ")}`);
|
|
319
|
+
lines.push(` primer kinds: ${v.primerKinds.join(", ")}`);
|
|
320
|
+
lines.push(` image models: ${v.imageModels.join(", ")}`);
|
|
321
|
+
lines.push(` aspect ratios: ${v.aspectRatios.join(", ")}`);
|
|
322
|
+
lines.push(` custom bot: set a bot node's slug to "${catalog.customBot.slug}" and ` +
|
|
323
|
+
`config.${catalog.customBot.configKey}=<genesis-slug> to reach any bot not listed here.`);
|
|
324
|
+
return lines.join("\n");
|
|
325
|
+
}
|
|
326
|
+
export function formatBotsList(catalog, category) {
|
|
327
|
+
const bots = category
|
|
328
|
+
? catalog.bots.filter((b) => b.category === category)
|
|
329
|
+
: catalog.bots;
|
|
330
|
+
if (bots.length === 0) {
|
|
331
|
+
const known = catalog.vocabulary.categories.map((c) => c.id).join(", ");
|
|
332
|
+
return category
|
|
333
|
+
? `No bots in category "${category}". Known categories: ${known}`
|
|
334
|
+
: "No bots in the catalog.";
|
|
335
|
+
}
|
|
336
|
+
const lines = [];
|
|
337
|
+
let currentCategory = null;
|
|
338
|
+
for (const bot of bots) {
|
|
339
|
+
if (bot.category !== currentCategory) {
|
|
340
|
+
if (currentCategory !== null)
|
|
341
|
+
lines.push("");
|
|
342
|
+
lines.push(`${bot.categoryLabel} (${bot.category})`);
|
|
343
|
+
currentCategory = bot.category;
|
|
344
|
+
}
|
|
345
|
+
lines.push(` ${bot.slug} — ${bot.blurb}`);
|
|
346
|
+
}
|
|
347
|
+
if (!category) {
|
|
348
|
+
lines.push("");
|
|
349
|
+
lines.push(formatBotVocabulary(catalog));
|
|
350
|
+
}
|
|
351
|
+
return lines.join("\n");
|
|
352
|
+
}
|
|
353
|
+
export function formatBotDetail(bot) {
|
|
354
|
+
const lines = [];
|
|
355
|
+
lines.push(`${bot.name} (slug: ${bot.slug})`);
|
|
356
|
+
lines.push(`category: ${bot.categoryLabel} (${bot.category})`);
|
|
357
|
+
lines.push(`blurb: ${bot.blurb}`);
|
|
358
|
+
lines.push(`outputType: ${bot.outputType}`);
|
|
359
|
+
lines.push(`splits: ${bot.splitsOutput ? "yes (can split into per-item deliverables)" : "no"}`);
|
|
360
|
+
lines.push("");
|
|
361
|
+
lines.push(`Input ports (${bot.inputs.length}):`);
|
|
362
|
+
if (bot.inputs.length === 0) {
|
|
363
|
+
lines.push(" (none)");
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
for (const input of bot.inputs) {
|
|
367
|
+
const bits = [`accepts ${input.accepts.join("/")}`, input.required ? "required" : "optional"];
|
|
368
|
+
if (input.multi)
|
|
369
|
+
bits.push("multi");
|
|
370
|
+
if (input.primerKinds && input.primerKinds.length > 0) {
|
|
371
|
+
bits.push(`primer-gate: ${input.primerKinds.join("/")}`);
|
|
372
|
+
}
|
|
373
|
+
lines.push(` ${input.id} — ${bits.join(", ")}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
lines.push("");
|
|
377
|
+
lines.push(`Params (${bot.params.length}):`);
|
|
378
|
+
if (bot.params.length === 0) {
|
|
379
|
+
lines.push(" (none)");
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
for (const param of bot.params) {
|
|
383
|
+
const bits = [param.kind];
|
|
384
|
+
if (param.options && param.options.length > 0)
|
|
385
|
+
bits.push(`options: ${param.options.join("|")}`);
|
|
386
|
+
if (param.min !== undefined)
|
|
387
|
+
bits.push(`min ${param.min}`);
|
|
388
|
+
if (param.max !== undefined)
|
|
389
|
+
bits.push(`max ${param.max}`);
|
|
390
|
+
if (param.required)
|
|
391
|
+
bits.push("required");
|
|
392
|
+
if (param.default !== undefined)
|
|
393
|
+
bits.push(`default "${param.default}"`);
|
|
394
|
+
lines.push(` config.${param.key} — ${param.label} (${bits.join(", ")})`);
|
|
395
|
+
if (param.help)
|
|
396
|
+
lines.push(` ${param.help}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return lines.join("\n");
|
|
400
|
+
}
|
|
401
|
+
function graphIssueLines(issue) {
|
|
402
|
+
const node = issue.nodeId ? ` [node ${issue.nodeId}]` : "";
|
|
403
|
+
const port = issue.portId ? ` [port ${issue.portId}]` : "";
|
|
404
|
+
const edge = !issue.nodeId && issue.edgeId ? ` [edge ${issue.edgeId}]` : "";
|
|
405
|
+
const out = [`${issue.code}${node}${edge}${port}: ${issue.message}`];
|
|
406
|
+
if (issue.remedy)
|
|
407
|
+
out.push(` fix: ${issue.remedy}`);
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
function formatImportError(res) {
|
|
411
|
+
const data = isRecord(res.data) ? res.data : {};
|
|
412
|
+
const err = isRecord(data.error) ? data.error : {};
|
|
413
|
+
const code = typeof err.code === "string" ? err.code : undefined;
|
|
414
|
+
const message = typeof err.message === "string" ? err.message : "Import failed";
|
|
415
|
+
if (code === "INVALID_GRAPH") {
|
|
416
|
+
const issues = Array.isArray(data.issues) ? data.issues : [];
|
|
417
|
+
const lines = [`Import rejected — invalid graph: ${message}`];
|
|
418
|
+
if (issues.length > 0) {
|
|
419
|
+
lines.push("");
|
|
420
|
+
lines.push(`Issues (${issues.length}):`);
|
|
421
|
+
for (const issue of issues)
|
|
422
|
+
lines.push(...graphIssueLines(issue));
|
|
423
|
+
}
|
|
424
|
+
return lines.join("\n");
|
|
425
|
+
}
|
|
426
|
+
if (code === "CONFLICT") {
|
|
427
|
+
const lines = [`Import conflict — ${message}`];
|
|
428
|
+
if (typeof data.currentUpdatedAt === "string") {
|
|
429
|
+
lines.push(`current updatedAt: ${data.currentUpdatedAt}`);
|
|
430
|
+
}
|
|
431
|
+
const remedy = typeof data.remedy === "string"
|
|
432
|
+
? data.remedy
|
|
433
|
+
: "Re-export the workflow, reapply your edits, and import again with the fresh updatedAt.";
|
|
434
|
+
lines.push(`fix: ${remedy}`);
|
|
435
|
+
return lines.join("\n");
|
|
436
|
+
}
|
|
437
|
+
return formatError(res);
|
|
438
|
+
}
|
|
439
|
+
export async function resolveWorkflowId(ref, deps) {
|
|
440
|
+
const res = await deps.get(LIST_PATH);
|
|
441
|
+
if (!res.ok)
|
|
442
|
+
throw new Error(formatError(res));
|
|
443
|
+
const workflows = res.data.workflows ?? [];
|
|
444
|
+
const match = workflows.find((w) => w.name.toLowerCase() === ref.toLowerCase());
|
|
445
|
+
return match?._id ?? ref;
|
|
446
|
+
}
|
|
447
|
+
export async function listFlow(json, deps) {
|
|
448
|
+
const res = await deps.get(LIST_PATH);
|
|
449
|
+
if (!res.ok)
|
|
450
|
+
return asErrorResult(res, json);
|
|
451
|
+
const data = res.data;
|
|
452
|
+
return {
|
|
453
|
+
code: 0,
|
|
454
|
+
lines: json ? [JSON.stringify(data)] : [formatWorkflowList(data.workflows ?? [])],
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
export async function describeFlow(workflowRef, opts, deps) {
|
|
458
|
+
let workflowId;
|
|
459
|
+
try {
|
|
460
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
461
|
+
}
|
|
462
|
+
catch (e) {
|
|
463
|
+
return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
|
|
464
|
+
}
|
|
465
|
+
const res = await deps.get(`${DESCRIBE_PATH}?id=${encodeURIComponent(workflowId)}`);
|
|
466
|
+
if (!res.ok)
|
|
467
|
+
return asErrorResult(res, opts.json);
|
|
468
|
+
if (opts.json)
|
|
469
|
+
return { code: 0, lines: [JSON.stringify(res.data)] };
|
|
470
|
+
return { code: 0, lines: [formatDescribe(res.data)] };
|
|
471
|
+
}
|
|
472
|
+
export async function botsFlow(opts, deps) {
|
|
473
|
+
const res = await deps.get(CATALOG_PATH);
|
|
474
|
+
if (!res.ok)
|
|
475
|
+
return asErrorResult(res, opts.json);
|
|
476
|
+
const catalog = res.data;
|
|
477
|
+
if (opts.slug) {
|
|
478
|
+
const bot = catalog.bots.find((b) => b.slug === opts.slug);
|
|
479
|
+
if (!bot) {
|
|
480
|
+
const line = opts.json
|
|
481
|
+
? JSON.stringify({ ok: false, error: `unknown bot slug "${opts.slug}"` })
|
|
482
|
+
: `No bot with slug "${opts.slug}" in the catalog. Run "exodus workflow bots" to list them.`;
|
|
483
|
+
return { code: 1, lines: [line] };
|
|
484
|
+
}
|
|
485
|
+
return { code: 0, lines: [opts.json ? JSON.stringify(bot) : formatBotDetail(bot)] };
|
|
486
|
+
}
|
|
487
|
+
if (opts.json)
|
|
488
|
+
return { code: 0, lines: [JSON.stringify(catalog)] };
|
|
489
|
+
return { code: 0, lines: [formatBotsList(catalog, opts.category)] };
|
|
490
|
+
}
|
|
491
|
+
export async function runFlow(workflowRef, opts, deps) {
|
|
492
|
+
let workflowId;
|
|
493
|
+
try {
|
|
494
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
|
|
498
|
+
}
|
|
499
|
+
const body = {
|
|
500
|
+
workflowId,
|
|
501
|
+
...(Object.keys(opts.inputs).length > 0 ? { inputs: opts.inputs } : {}),
|
|
502
|
+
};
|
|
503
|
+
const start = await deps.post(RUN_PATH, body);
|
|
504
|
+
if (!start.ok)
|
|
505
|
+
return asErrorResult(start, opts.json);
|
|
506
|
+
const data = start.data;
|
|
507
|
+
const base = { ...data, workflowId };
|
|
508
|
+
if (opts.json && !opts.wait)
|
|
509
|
+
return { code: 0, lines: [JSON.stringify(base)] };
|
|
510
|
+
const lines = opts.json
|
|
511
|
+
? []
|
|
512
|
+
: [
|
|
513
|
+
"Workflow run started.",
|
|
514
|
+
`runId: ${data.runId}`,
|
|
515
|
+
`triggerRunId: ${data.triggerRunId}`,
|
|
516
|
+
`Poll: exodus workflow status --id ${data.runId}`,
|
|
517
|
+
];
|
|
518
|
+
if (!opts.wait)
|
|
519
|
+
return { code: 0, lines };
|
|
520
|
+
if (!opts.json && opts.onProgressLine) {
|
|
521
|
+
for (const line of lines)
|
|
522
|
+
opts.onProgressLine(line);
|
|
523
|
+
lines.length = 0;
|
|
524
|
+
}
|
|
525
|
+
const seen = new Map();
|
|
526
|
+
const pollResult = await deps.poll({
|
|
527
|
+
path: `${STATUS_PATH}?runId=${encodeURIComponent(data.runId)}`,
|
|
528
|
+
intervalMs: 3_000,
|
|
529
|
+
timeoutMs: 60 * 60 * 1000,
|
|
530
|
+
terminalStatuses: ["completed", "partial", "failed"],
|
|
531
|
+
onProgress: (raw) => {
|
|
532
|
+
if (opts.json || !opts.onProgressLine)
|
|
533
|
+
return;
|
|
534
|
+
const nodes = Array.isArray(raw["nodes"]) ? raw["nodes"] : [];
|
|
535
|
+
for (const node of nodes) {
|
|
536
|
+
if (seen.get(node.nodeId) === node.status)
|
|
537
|
+
continue;
|
|
538
|
+
seen.set(node.nodeId, node.status);
|
|
539
|
+
opts.onProgressLine(progressLine(node));
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
if (opts.json) {
|
|
544
|
+
return {
|
|
545
|
+
code: pollResult.ok ? 0 : 1,
|
|
546
|
+
lines: [JSON.stringify({ ...base, result: pollResult.data, timedOut: pollResult.timedOut })],
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
if (pollResult.timedOut) {
|
|
550
|
+
lines.push(`Timed out waiting. Check later: exodus workflow status --id ${data.runId}`);
|
|
551
|
+
return { code: 1, lines };
|
|
552
|
+
}
|
|
553
|
+
lines.push("");
|
|
554
|
+
if (isRecord(pollResult.data) && typeof pollResult.data["_id"] === "string") {
|
|
555
|
+
lines.push(formatWorkflowRun(pollResult.data));
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
lines.push(`Polling failed: ${JSON.stringify(pollResult.data)}`);
|
|
559
|
+
}
|
|
560
|
+
return { code: pollResult.ok ? 0 : 1, lines };
|
|
561
|
+
}
|
|
562
|
+
export async function statusFlow(opts, deps) {
|
|
563
|
+
const path = opts.id ? `${STATUS_PATH}?runId=${encodeURIComponent(opts.id)}` : STATUS_PATH;
|
|
564
|
+
const res = await deps.get(path);
|
|
565
|
+
if (!res.ok)
|
|
566
|
+
return asErrorResult(res, opts.json);
|
|
567
|
+
if (opts.json)
|
|
568
|
+
return { code: 0, lines: [JSON.stringify(res.data)] };
|
|
569
|
+
if (opts.id)
|
|
570
|
+
return { code: 0, lines: [formatWorkflowRun(res.data)] };
|
|
571
|
+
const runs = (res.data.runs ?? []);
|
|
572
|
+
return { code: 0, lines: [formatRecentRuns(runs)] };
|
|
573
|
+
}
|
|
574
|
+
export async function exportFlow(workflowRef, opts, deps) {
|
|
575
|
+
let workflowId;
|
|
576
|
+
try {
|
|
577
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
|
|
581
|
+
}
|
|
582
|
+
const res = await deps.get(`${EXPORT_PATH}?id=${encodeURIComponent(workflowId)}`);
|
|
583
|
+
if (!res.ok)
|
|
584
|
+
return asErrorResult(res, false);
|
|
585
|
+
const doc = JSON.stringify(res.data, null, 2);
|
|
586
|
+
if (opts.out) {
|
|
587
|
+
deps.writeFile(opts.out, `${doc}\n`);
|
|
588
|
+
return { code: 0, lines: [`Wrote workflow contract to ${opts.out}.`] };
|
|
589
|
+
}
|
|
590
|
+
return { code: 0, lines: [doc] };
|
|
591
|
+
}
|
|
592
|
+
export async function importFlow(file, opts, deps) {
|
|
593
|
+
let text;
|
|
594
|
+
try {
|
|
595
|
+
text = deps.readFile(file);
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return { code: 1, lines: [`Error: file not found: ${file}`] };
|
|
599
|
+
}
|
|
600
|
+
let parsed;
|
|
601
|
+
try {
|
|
602
|
+
parsed = JSON.parse(text);
|
|
603
|
+
}
|
|
604
|
+
catch (e) {
|
|
605
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
606
|
+
return { code: 1, lines: [`Error: ${file} is not valid JSON: ${msg}`] };
|
|
607
|
+
}
|
|
608
|
+
if (!isRecord(parsed)) {
|
|
609
|
+
return { code: 1, lines: [`Error: ${file} is not a workflow contract (expected a JSON object).`] };
|
|
610
|
+
}
|
|
611
|
+
const body = { ...parsed };
|
|
612
|
+
delete body.dryRun;
|
|
613
|
+
delete body.targetWorkflowId;
|
|
614
|
+
delete body.expectedUpdatedAt;
|
|
615
|
+
if (opts.dryRun)
|
|
616
|
+
body.dryRun = true;
|
|
617
|
+
if (opts.update) {
|
|
618
|
+
const updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined;
|
|
619
|
+
if (!updatedAt) {
|
|
620
|
+
return {
|
|
621
|
+
code: 1,
|
|
622
|
+
lines: [
|
|
623
|
+
`Error: ${file} has no "updatedAt" anchor, so drift can't be detected. ` +
|
|
624
|
+
`Re-export the workflow first (exodus workflow export <id> --out ${file}) and retry --update.`,
|
|
625
|
+
],
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
body.targetWorkflowId = opts.update;
|
|
629
|
+
body.expectedUpdatedAt = updatedAt;
|
|
630
|
+
}
|
|
631
|
+
const res = await deps.post(IMPORT_PATH, body);
|
|
632
|
+
if (!res.ok) {
|
|
633
|
+
return {
|
|
634
|
+
code: 1,
|
|
635
|
+
lines: opts.json
|
|
636
|
+
? [JSON.stringify({ ok: false, status: res.status, data: res.data })]
|
|
637
|
+
: [formatImportError(res)],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const data = res.data;
|
|
641
|
+
return {
|
|
642
|
+
code: 0,
|
|
643
|
+
lines: opts.json
|
|
644
|
+
? [JSON.stringify(data)]
|
|
645
|
+
: [formatImportSummary(data, { dryRun: opts.dryRun, update: !!opts.update })],
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function flagString(flags, name) {
|
|
649
|
+
const v = flags[name];
|
|
650
|
+
return typeof v === "string" ? v : undefined;
|
|
651
|
+
}
|
|
652
|
+
function parsePositional(args = process.argv.slice(3)) {
|
|
653
|
+
const out = [];
|
|
654
|
+
let i = 0;
|
|
655
|
+
while (i < args.length) {
|
|
656
|
+
const arg = args[i];
|
|
657
|
+
if (arg.startsWith("--")) {
|
|
658
|
+
const key = arg.slice(2).split("=", 1)[0] ?? "";
|
|
659
|
+
if (!arg.includes("=") && VALUE_FLAGS.has(key))
|
|
660
|
+
i += 2;
|
|
661
|
+
else
|
|
662
|
+
i++;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
out.push(arg);
|
|
666
|
+
i++;
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
async function printResult(result) {
|
|
671
|
+
for (const line of result.lines)
|
|
672
|
+
console.log(line);
|
|
673
|
+
if (result.code !== 0)
|
|
674
|
+
process.exit(result.code);
|
|
675
|
+
}
|
|
676
|
+
export async function run(flags) {
|
|
677
|
+
const positional = parsePositional();
|
|
678
|
+
const [sub, ...rest] = positional;
|
|
679
|
+
const json = !!flags["json"];
|
|
680
|
+
if (!sub) {
|
|
681
|
+
console.log(helpText);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (sub === "list")
|
|
685
|
+
return printResult(await listFlow(json, defaultDeps));
|
|
686
|
+
if (sub === "describe") {
|
|
687
|
+
const workflowRef = rest[0];
|
|
688
|
+
if (!workflowRef) {
|
|
689
|
+
console.error("Error: workflow describe requires <workflowId|name>.");
|
|
690
|
+
console.log("Usage: exodus workflow describe <workflowId|name> [--json]");
|
|
691
|
+
process.exit(1);
|
|
692
|
+
}
|
|
693
|
+
return printResult(await describeFlow(workflowRef, { json }, defaultDeps));
|
|
694
|
+
}
|
|
695
|
+
if (sub === "bots") {
|
|
696
|
+
return printResult(await botsFlow({ category: flagString(flags, "category"), slug: flagString(flags, "slug"), json }, defaultDeps));
|
|
697
|
+
}
|
|
698
|
+
if (sub === "run") {
|
|
699
|
+
const workflowRef = rest[0];
|
|
700
|
+
if (!workflowRef) {
|
|
701
|
+
console.error("Error: workflow run requires <workflowId|name>.");
|
|
702
|
+
console.log("Usage: exodus workflow run <workflowId|name> [--input key=value ...] [--wait] [--json]");
|
|
703
|
+
process.exit(1);
|
|
704
|
+
}
|
|
705
|
+
let inputs;
|
|
706
|
+
try {
|
|
707
|
+
inputs = parseInputFlags(process.argv.slice(3), defaultDeps.readFile);
|
|
708
|
+
}
|
|
709
|
+
catch (e) {
|
|
710
|
+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
711
|
+
process.exit(1);
|
|
712
|
+
}
|
|
713
|
+
return printResult(await runFlow(workflowRef, {
|
|
714
|
+
inputs,
|
|
715
|
+
wait: flags["wait"] === true,
|
|
716
|
+
json,
|
|
717
|
+
onProgressLine: (line) => console.log(line),
|
|
718
|
+
}, defaultDeps));
|
|
719
|
+
}
|
|
720
|
+
if (sub === "status") {
|
|
721
|
+
return printResult(await statusFlow({ id: flagString(flags, "id"), json }, defaultDeps));
|
|
722
|
+
}
|
|
723
|
+
if (sub === "export") {
|
|
724
|
+
const workflowRef = rest[0];
|
|
725
|
+
if (!workflowRef) {
|
|
726
|
+
console.error("Error: workflow export requires <workflowId|name>.");
|
|
727
|
+
console.log("Usage: exodus workflow export <workflowId|name> [--out <file>]");
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
return printResult(await exportFlow(workflowRef, { out: flagString(flags, "out") }, defaultDeps));
|
|
731
|
+
}
|
|
732
|
+
if (sub === "import") {
|
|
733
|
+
const file = rest[0];
|
|
734
|
+
if (!file) {
|
|
735
|
+
console.error("Error: workflow import requires <file>.");
|
|
736
|
+
console.log("Usage: exodus workflow import <file> [--update <workflowId>] [--dry-run] [--json]");
|
|
737
|
+
process.exit(1);
|
|
738
|
+
}
|
|
739
|
+
return printResult(await importFlow(file, { dryRun: flags["dry-run"] === true, json, update: flagString(flags, "update") }, defaultDeps));
|
|
740
|
+
}
|
|
741
|
+
console.error(`Unknown subcommand: "${sub}"\n`);
|
|
742
|
+
console.log(helpText);
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|