@aicopycoders/exodus 2026.7.200-beta.11 → 2026.7.200-beta.13
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/README.md +51 -0
- package/assets/docs/CLAUDE.md +1 -0
- package/assets/docs/PIPELINES.md +26 -0
- package/assets/skills/exodus/SKILL.md +4 -1
- package/assets/skills/exodus-workflow/SKILL.md +235 -61
- package/dist/bin/exodus.js +2 -0
- package/dist/commands/bank.d.ts +88 -0
- package/dist/commands/bank.js +346 -0
- package/dist/commands/keys.js +1 -0
- package/dist/commands/session.d.ts +55 -0
- package/dist/commands/session.js +202 -0
- package/dist/commands/workflow.d.ts +192 -4
- package/dist/commands/workflow.js +1462 -69
- package/dist/lib/client.d.ts +8 -1
- package/dist/lib/client.js +30 -6
- package/dist/lib/format.d.ts +5 -0
- package/dist/lib/format.js +31 -0
- package/dist/lib/keys-sync.js +1 -0
- package/dist/lib/route-support.d.ts +4 -0
- package/dist/lib/route-support.js +22 -0
- package/dist/lib/workflowText.d.ts +3 -0
- package/dist/lib/workflowText.js +113 -0
- package/package.json +5 -2
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { apiGet, apiPost } from "../lib/client.js";
|
|
3
|
-
import {
|
|
2
|
+
import { apiGet, apiGetText, apiPost, apiPostDashboard, getDashboardUrl, } from "../lib/client.js";
|
|
3
|
+
import { formatApiError } from "../lib/format.js";
|
|
4
4
|
import { pollUntilDone } from "../lib/poll.js";
|
|
5
|
+
import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
|
|
6
|
+
import { missingRouteLine } from "../lib/route-support.js";
|
|
7
|
+
import { getChannel } from "../lib/channel.js";
|
|
5
8
|
export const helpText = `
|
|
6
9
|
exodus workflow — List, describe, run, inspect, import, and export saved workflows
|
|
7
10
|
|
|
@@ -9,10 +12,28 @@ Usage:
|
|
|
9
12
|
exodus workflow list [--json]
|
|
10
13
|
exodus workflow describe <workflowId|name> [--json]
|
|
11
14
|
exodus workflow bots [--category <cat>] [--slug <slug>] [--json]
|
|
12
|
-
exodus workflow
|
|
15
|
+
exodus workflow templates [list] [--json]
|
|
16
|
+
exodus workflow templates export <key> [--out <file>] [--json]
|
|
17
|
+
exodus workflow schema [--kind <kind>] [--face <face>] [--json]
|
|
18
|
+
exodus workflow run <workflowId|name> [--input key=value ...] [--terminal <nodeId> ...] [--wait] [--json]
|
|
13
19
|
exodus workflow status [--id <runId>] [--json]
|
|
14
|
-
exodus workflow
|
|
20
|
+
exodus workflow versions <workflowId|name> [--json]
|
|
21
|
+
exodus workflow export <workflowId|name> [--version <n>] [--out <file>] [--json]
|
|
22
|
+
exodus workflow validate <file> [--update <workflowId>] [--json]
|
|
15
23
|
exodus workflow import <file> [--update <workflowId>] [--dry-run] [--json]
|
|
24
|
+
exodus workflow triggers <workflowId|name> [--json]
|
|
25
|
+
exodus workflow triggers <workflowId|name> enable <n> [--json]
|
|
26
|
+
exodus workflow triggers <workflowId|name> disable <n> [--json]
|
|
27
|
+
exodus workflow triggers <workflowId|name> fire [<n>] [--text "..."] [--wait] [--json]
|
|
28
|
+
exodus workflow inbox [--json]
|
|
29
|
+
exodus workflow gate <runId> [--json]
|
|
30
|
+
exodus workflow gate <runId> pick <n,..> [--json]
|
|
31
|
+
exodus workflow gate <runId> edit <n> [--text "..." | --file <path> | (stdin)] [--json]
|
|
32
|
+
exodus workflow gate <runId> push "<msg>" [--json]
|
|
33
|
+
exodus workflow gate <runId> approve [--wait] [--json]
|
|
34
|
+
exodus workflow gate <runId> reject [--reason "..."] [--json]
|
|
35
|
+
exodus workflow repair <runId> retry|skip|kill [--wait] [--json]
|
|
36
|
+
exodus workflow answer <runId> --slot key=value [--slot key=value ...] [--json]
|
|
16
37
|
|
|
17
38
|
Flags:
|
|
18
39
|
--json Machine-readable JSON output
|
|
@@ -22,31 +43,113 @@ Flags:
|
|
|
22
43
|
--input key=@path loads the value from a file (path is
|
|
23
44
|
resolved from the current directory); --input key=@@text
|
|
24
45
|
keeps a leading "@" as a literal character.
|
|
46
|
+
--terminal <nodeId> (run) Repeatable. Scope the run to the upstream closure
|
|
47
|
+
of these end node(s) — only nodes feeding a picked
|
|
48
|
+
terminal execute; the rest are recorded out-of-scope.
|
|
49
|
+
Omit to run the whole graph.
|
|
25
50
|
--wait Poll until the workflow run reaches a terminal status
|
|
26
51
|
--id <runId> Workflow run id for status detail
|
|
27
|
-
--out <file> Write export
|
|
28
|
-
--
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
--out <file> Write the export to a file instead of stdout
|
|
53
|
+
--version <n> (export) Export a saved historical version instead of the
|
|
54
|
+
current head. <n> is the real 1-based version id from
|
|
55
|
+
"workflow versions" (a positive integer). A version
|
|
56
|
+
export intentionally carries NO triggers/description —
|
|
57
|
+
those aren't versioned, so a rollback leaves them
|
|
58
|
+
unchanged; head exports remain the only place triggers
|
|
59
|
+
appear.
|
|
60
|
+
--json (export) Emit the legacy JSON contract body instead of
|
|
61
|
+
the default canonical YAML (escape hatch for tools that
|
|
62
|
+
still parse the old JSON export byte-for-byte)
|
|
63
|
+
--update <workflowId> (import/validate) Update this existing workflow in place
|
|
64
|
+
instead of creating a new one. Sends the contract's
|
|
65
|
+
updatedAt as an optimistic-concurrency guard — if it
|
|
66
|
+
409s, re-export the workflow, reapply edits, and retry.
|
|
67
|
+
--dry-run Preview import (validate + resolve refs) without writing.
|
|
68
|
+
Same server-side check as "workflow validate <file>" —
|
|
69
|
+
use validate as the standalone front door.
|
|
70
|
+
--text "..." (triggers fire) The input an EVENT trigger's run carries.
|
|
71
|
+
Required for event triggers; rejected for cron triggers.
|
|
72
|
+
(gate edit) The replacement copy for one candidate.
|
|
73
|
+
--file <path> (gate edit) Load the replacement copy from a file.
|
|
74
|
+
--reason "..." (gate reject) Optional reason recorded on the cancel.
|
|
75
|
+
--slot key=value (answer) Repeatable. One answer per pending slot.
|
|
76
|
+
--kind <kind> (schema) Print just one node kind's ports + config rules
|
|
77
|
+
--face <face> (schema) Print just one transform face's spec
|
|
33
78
|
|
|
34
79
|
Examples:
|
|
35
80
|
exodus workflow describe "Launch Flow"
|
|
36
81
|
exodus workflow bots
|
|
37
82
|
exodus workflow bots --category writing
|
|
38
83
|
exodus workflow bots --slug new-hook-bot
|
|
84
|
+
exodus workflow templates
|
|
85
|
+
exodus workflow templates export complete-ad-set --out my.yaml
|
|
86
|
+
exodus workflow schema
|
|
87
|
+
exodus workflow schema --kind transform
|
|
88
|
+
exodus workflow schema --face splitter
|
|
39
89
|
exodus workflow run "Launch Flow" --input brief="new offer" --wait
|
|
40
90
|
exodus workflow run "Launch Flow" --input brief=@brief.txt
|
|
91
|
+
exodus workflow run "Launch Flow" --terminal bot-3 --terminal image-2
|
|
41
92
|
exodus workflow status --id wr_123
|
|
42
|
-
exodus workflow
|
|
43
|
-
exodus workflow
|
|
93
|
+
exodus workflow versions "Launch Flow"
|
|
94
|
+
exodus workflow export "Launch Flow" --out workflow.yaml
|
|
95
|
+
exodus workflow export "Launch Flow" --version 3 --out v3.yaml
|
|
96
|
+
exodus workflow export "Launch Flow" --json --out workflow.json
|
|
97
|
+
exodus workflow validate my.yaml
|
|
98
|
+
exodus workflow import workflow.yaml --dry-run
|
|
44
99
|
exodus workflow import workflow.json --update wf_123
|
|
100
|
+
exodus workflow triggers "Winner Flywheel"
|
|
101
|
+
exodus workflow triggers "Winner Flywheel" enable 1
|
|
102
|
+
exodus workflow triggers "Winner Flywheel" fire 1 --text "new offer" --wait
|
|
103
|
+
exodus workflow inbox
|
|
104
|
+
exodus workflow gate wr_123
|
|
105
|
+
exodus workflow gate wr_123 pick 1,3
|
|
106
|
+
exodus workflow gate wr_123 edit 2 --text "punchier hook"
|
|
107
|
+
exodus workflow gate wr_123 push "make it shorter"
|
|
108
|
+
exodus workflow gate wr_123 approve --wait
|
|
109
|
+
exodus workflow gate wr_123 reject --reason "off-brand"
|
|
110
|
+
exodus workflow repair wr_123 retry --wait
|
|
111
|
+
exodus workflow answer wr_123 --slot tone=casual --slot length=short
|
|
112
|
+
|
|
113
|
+
# Diff two saved versions:
|
|
114
|
+
exodus workflow export X --version 3 --out v3.yaml && exodus workflow export X --version 5 --out v5.yaml && diff v3.yaml v5.yaml
|
|
115
|
+
# Roll a workflow back to an earlier version:
|
|
116
|
+
exodus workflow export X --version 3 --out v3.yaml && exodus workflow import v3.yaml --update <id>
|
|
45
117
|
|
|
46
118
|
Notes:
|
|
119
|
+
Cold-start is a template, not a blank file: "workflow templates" lists the
|
|
120
|
+
starters (incl. Winner Flywheel), "workflow templates export <key> --out f.yaml"
|
|
121
|
+
writes the server-rendered YAML verbatim — edit it, then "workflow import f.yaml".
|
|
122
|
+
"workflow schema" prints the LIVE graph vocabulary (node kinds, ports, config
|
|
123
|
+
rules, transform faces, gate policies, wiring rules) from the backend you're
|
|
124
|
+
deployed against, so what you author matches what will validate; --kind/--face
|
|
125
|
+
narrow it, --json is the machine payload. "workflow validate <file>" checks a
|
|
126
|
+
file against the live backend (it IS import --dry-run under its own door).
|
|
127
|
+
workflow triggers are addressed by 1-based position — a trigger carries no id,
|
|
128
|
+
so the CLI reads the live list from the export contract and sends a fingerprint
|
|
129
|
+
of the trigger's fields as the guard; a concurrent edit fails loud rather than
|
|
130
|
+
flipping the wrong trigger. Add or remove triggers by editing the YAML export.
|
|
131
|
+
workflow export writes canonical YAML by default (human-diffable, key order
|
|
132
|
+
fixed so equal workflows dump byte-identically); pass --json for the legacy
|
|
133
|
+
JSON contract body. workflow import accepts either a YAML export or a legacy
|
|
134
|
+
JSON file — same downstream, so YAML files work against the deployed API.
|
|
135
|
+
workflow versions lists a workflow's saved history newest-first (up to 50);
|
|
136
|
+
version numbers are real 1-based ids you pass to export --version. A version
|
|
137
|
+
export intentionally carries NO triggers/description (they aren't versioned),
|
|
138
|
+
so rolling back with import --update leaves them unchanged — head exports
|
|
139
|
+
remain the only place triggers appear.
|
|
47
140
|
workflow bots --json emits the FULL catalog response verbatim (--category /
|
|
48
141
|
--slug filters are ignored in that mode). workflow bots --slug <slug> --json
|
|
49
142
|
emits just that one bot's catalog JSON.
|
|
143
|
+
workflow inbox lists every run parked waiting on you, badged by park kind
|
|
144
|
+
(gate/repair/slots/legacy) and how it started (bg / trig:<event>). A gate park
|
|
145
|
+
is resolved with the "gate" verbs: pick candidates by their 1-based number
|
|
146
|
+
(pick 1,3), edit one candidate's copy in place (edit 2 --text ...), push a
|
|
147
|
+
steering message into the gate's live chat session to bank a fresh candidate
|
|
148
|
+
(push "..."), then approve (resume) or reject (cancel). A require-all
|
|
149
|
+
collector that stalled on a dead input is a "repair" park — retry it, skip the
|
|
150
|
+
dead input, or kill the run. A nested sub-workflow waiting on inputs is a
|
|
151
|
+
"slots" park — answer it with repeatable --slot key=value flags (run "answer"
|
|
152
|
+
with no --slot to list the slot ids it wants).
|
|
50
153
|
`.trim();
|
|
51
154
|
const LIST_PATH = "/api/v2/workflows";
|
|
52
155
|
const RUN_PATH = "/api/v2/workflows/run";
|
|
@@ -55,20 +158,73 @@ const EXPORT_PATH = "/api/v2/workflows/export";
|
|
|
55
158
|
const IMPORT_PATH = "/api/v2/workflows/import";
|
|
56
159
|
const DESCRIBE_PATH = "/api/v2/workflows/describe";
|
|
57
160
|
const CATALOG_PATH = "/api/v2/workflows/catalog";
|
|
58
|
-
const
|
|
161
|
+
const TRIGGERS_SET_ENABLED_PATH = "/api/v2/workflows/triggers/set-enabled";
|
|
162
|
+
const TRIGGERS_FIRE_PATH = "/api/v2/workflows/triggers/fire";
|
|
163
|
+
const TEMPLATES_PATH = "/api/v2/workflows/templates";
|
|
164
|
+
const SCHEMA_PATH = "/api/v2/workflows/schema";
|
|
165
|
+
const VERSIONS_PATH = "/api/v2/workflows/versions";
|
|
166
|
+
const VERSIONS_CAP = 50;
|
|
167
|
+
const INBOX_PATH = "/api/v2/workflow/inbox";
|
|
168
|
+
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
169
|
+
const GATE_PICK_PATH = "/api/v2/workflow/gate/pick";
|
|
170
|
+
const GATE_EDIT_PATH = "/api/v2/workflow/gate/edit";
|
|
171
|
+
const GATE_APPEND_PATH = "/api/v2/workflow/gate/append-from-session";
|
|
172
|
+
const CANCEL_PATH = "/api/v2/workflow/cancel";
|
|
173
|
+
const ANSWER_PATH = "/api/v2/workflow/answer";
|
|
174
|
+
const REPAIR_RETRY_PATH = "/api/v2/workflow/repair/retry";
|
|
175
|
+
const REPAIR_SKIP_PATH = "/api/v2/workflow/repair/skip";
|
|
176
|
+
const CHAT_PATH = "/api/sessions/chat";
|
|
177
|
+
const CHAT_TIMEOUT_MS = 320_000;
|
|
178
|
+
const RUN_PAGE_PREFIX = "/runs/";
|
|
179
|
+
const VALUE_FLAGS = new Set([
|
|
180
|
+
"id",
|
|
181
|
+
"input",
|
|
182
|
+
"out",
|
|
183
|
+
"category",
|
|
184
|
+
"slug",
|
|
185
|
+
"update",
|
|
186
|
+
"terminal",
|
|
187
|
+
"text",
|
|
188
|
+
"kind",
|
|
189
|
+
"face",
|
|
190
|
+
"version",
|
|
191
|
+
"file",
|
|
192
|
+
"reason",
|
|
193
|
+
"slot",
|
|
194
|
+
]);
|
|
59
195
|
const defaultDeps = {
|
|
60
196
|
get: (path) => apiGet(path),
|
|
197
|
+
getText: (path) => apiGetText(path),
|
|
61
198
|
post: (path, body) => apiPost(path, body),
|
|
62
199
|
readFile: (path) => fs.readFileSync(path, "utf-8"),
|
|
63
200
|
writeFile: (path, text) => fs.writeFileSync(path, text, "utf-8"),
|
|
64
201
|
poll: (opts) => pollUntilDone(opts),
|
|
202
|
+
postDashboard: (path, body, opts) => apiPostDashboard(path, body, opts),
|
|
203
|
+
dashboardUrl: getDashboardUrl(),
|
|
65
204
|
};
|
|
66
205
|
function asErrorResult(res, json) {
|
|
67
206
|
return {
|
|
68
207
|
code: 1,
|
|
69
208
|
lines: json
|
|
70
209
|
? [JSON.stringify({ ok: false, status: res.status, data: res.data })]
|
|
71
|
-
: [
|
|
210
|
+
: [formatApiError(res)],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function resolveIdErrorResult(e, json) {
|
|
214
|
+
if (e instanceof WorkflowResolveError) {
|
|
215
|
+
return {
|
|
216
|
+
code: 1,
|
|
217
|
+
lines: json
|
|
218
|
+
? [JSON.stringify({ ok: false, status: e.status, data: e.data })]
|
|
219
|
+
: [e.message],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
223
|
+
return {
|
|
224
|
+
code: 1,
|
|
225
|
+
lines: json
|
|
226
|
+
? [JSON.stringify({ ok: false, status: 0, data: { error: { message } } })]
|
|
227
|
+
: [message],
|
|
72
228
|
};
|
|
73
229
|
}
|
|
74
230
|
function isRecord(value) {
|
|
@@ -113,12 +269,47 @@ function outputLines(output) {
|
|
|
113
269
|
if (output.type === "primer") {
|
|
114
270
|
return [` primer:${output.primerKind}: ${truncateText(output.text)}`];
|
|
115
271
|
}
|
|
116
|
-
|
|
272
|
+
if (output.type === "image") {
|
|
273
|
+
const url = output.imageUrl ?? output.storageId;
|
|
274
|
+
return url ? [` image: ${url}`] : [];
|
|
275
|
+
}
|
|
276
|
+
return [];
|
|
117
277
|
}
|
|
118
|
-
function progressLine(node) {
|
|
278
|
+
function progressLine(node, parked = false) {
|
|
119
279
|
const err = node.error ? ` — error: ${node.error}` : "";
|
|
280
|
+
if (parked) {
|
|
281
|
+
return ` ⏸ ${node.nodeId} (${node.kind}) awaiting review`;
|
|
282
|
+
}
|
|
120
283
|
return ` ${statusIcon(node.status)} ${node.nodeId} (${node.kind}) ${node.status}${err}`;
|
|
121
284
|
}
|
|
285
|
+
function gateParkedNodeId(status, pauseReason, pausedNodeId) {
|
|
286
|
+
if (status !== "awaiting-review")
|
|
287
|
+
return undefined;
|
|
288
|
+
if (pauseReason === "taste" || pauseReason === undefined)
|
|
289
|
+
return pausedNodeId;
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
export function formatPauseNotice(pauseReason, runId, dashboardUrl) {
|
|
293
|
+
if (!pauseReason) {
|
|
294
|
+
return [
|
|
295
|
+
" ⏸ paused at the cost gate — approve or edit the storyboard in the web app to continue.",
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
if (pauseReason === "call") {
|
|
299
|
+
return [" ⏸ waiting on a child workflow run — it resumes on its own."];
|
|
300
|
+
}
|
|
301
|
+
const resolveVerb = pauseReason === "repair"
|
|
302
|
+
? `exodus workflow repair ${runId} retry|skip|kill`
|
|
303
|
+
: pauseReason === "slots"
|
|
304
|
+
? `exodus workflow answer ${runId} --slot key=value`
|
|
305
|
+
:
|
|
306
|
+
`exodus workflow gate ${runId}`;
|
|
307
|
+
return [
|
|
308
|
+
" ⏸ paused for review — waiting on you.",
|
|
309
|
+
` Resolve here: ${resolveVerb}`,
|
|
310
|
+
` Or in the app: ${dashboardUrl}${RUN_PAGE_PREFIX}${runId}`,
|
|
311
|
+
];
|
|
312
|
+
}
|
|
122
313
|
function expandInputValue(key, value, readFile) {
|
|
123
314
|
if (value.startsWith("@@"))
|
|
124
315
|
return value.slice(1);
|
|
@@ -167,6 +358,30 @@ export function parseInputFlags(args, readFile) {
|
|
|
167
358
|
}
|
|
168
359
|
return inputs;
|
|
169
360
|
}
|
|
361
|
+
export function parseTerminalFlags(args) {
|
|
362
|
+
const ids = [];
|
|
363
|
+
for (let i = 0; i < args.length; i++) {
|
|
364
|
+
const arg = args[i];
|
|
365
|
+
let raw;
|
|
366
|
+
if (arg === "--terminal") {
|
|
367
|
+
raw = args[i + 1];
|
|
368
|
+
i++;
|
|
369
|
+
}
|
|
370
|
+
else if (arg.startsWith("--terminal=")) {
|
|
371
|
+
raw = arg.slice("--terminal=".length);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (raw === undefined)
|
|
377
|
+
throw new Error("--terminal requires a node id");
|
|
378
|
+
const id = raw.trim();
|
|
379
|
+
if (!id)
|
|
380
|
+
throw new Error("--terminal requires a node id");
|
|
381
|
+
ids.push(id);
|
|
382
|
+
}
|
|
383
|
+
return ids;
|
|
384
|
+
}
|
|
170
385
|
export function formatWorkflowList(workflows) {
|
|
171
386
|
if (workflows.length === 0)
|
|
172
387
|
return "No workflows found for the active brand.";
|
|
@@ -183,21 +398,59 @@ export function formatRecentRuns(runs) {
|
|
|
183
398
|
return "No workflow runs found for the active brand.";
|
|
184
399
|
return table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, r.status, dateOnly(r.createdAt), r._id]));
|
|
185
400
|
}
|
|
401
|
+
export function formatWorkflowVersions(versions) {
|
|
402
|
+
if (versions.length === 0) {
|
|
403
|
+
return "no saved versions yet — versions start recording on the workflow's next save";
|
|
404
|
+
}
|
|
405
|
+
const lines = versions.map((v) => {
|
|
406
|
+
const by = v.savedByName ? ` · by ${v.savedByName}` : "";
|
|
407
|
+
return `v${v.version} · ${v.name} · saved ${dateOnly(v.savedAt)}${by}`;
|
|
408
|
+
});
|
|
409
|
+
if (versions.length === VERSIONS_CAP) {
|
|
410
|
+
lines.push("");
|
|
411
|
+
lines.push(`(showing the ${VERSIONS_CAP} most recent versions — older versions may exist beyond this cap)`);
|
|
412
|
+
}
|
|
413
|
+
return lines.join("\n");
|
|
414
|
+
}
|
|
186
415
|
export function formatImportSummary(result, mode = {}) {
|
|
187
416
|
const lines = [];
|
|
188
|
-
const heading = mode.
|
|
189
|
-
? "Workflow
|
|
190
|
-
:
|
|
191
|
-
? "Workflow
|
|
192
|
-
:
|
|
193
|
-
? "Workflow
|
|
194
|
-
:
|
|
417
|
+
const heading = mode.validate
|
|
418
|
+
? "Workflow validation passed:"
|
|
419
|
+
: mode.dryRun
|
|
420
|
+
? "Workflow import preview:"
|
|
421
|
+
: result.created
|
|
422
|
+
? "Workflow imported."
|
|
423
|
+
: mode.update
|
|
424
|
+
? "Workflow updated."
|
|
425
|
+
: "Workflow import preview:";
|
|
195
426
|
lines.push(heading);
|
|
196
427
|
lines.push(`name: ${result.name}`);
|
|
197
428
|
if (result.workflowId)
|
|
198
429
|
lines.push(`workflowId: ${result.workflowId}`);
|
|
199
430
|
lines.push(`nodes: ${result.nodeCount}`);
|
|
200
431
|
lines.push(`edges: ${result.edgeCount}`);
|
|
432
|
+
if (result.triggers && result.triggers.length > 0) {
|
|
433
|
+
lines.push("");
|
|
434
|
+
const enabledCount = result.triggers.filter((t) => t.enabled).length;
|
|
435
|
+
lines.push(`Triggers (${result.triggers.length}):`);
|
|
436
|
+
for (const t of result.triggers) {
|
|
437
|
+
const detail = t.type === "event" ? `event (${t.event})` : `cron (${t.cron})`;
|
|
438
|
+
if (t.enabled) {
|
|
439
|
+
const fires = t.type === "event"
|
|
440
|
+
? t.event === "winner-promoted"
|
|
441
|
+
? "fires on every promote"
|
|
442
|
+
: `fires on ${t.event}`
|
|
443
|
+
: `fires on schedule ${t.cron}`;
|
|
444
|
+
lines.push(` ⚠ ${detail} — ENABLED, ${fires}`);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
lines.push(` ${detail} — disabled`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (enabledCount > 0) {
|
|
451
|
+
lines.push(` ⚠ ${enabledCount} enabled trigger${enabledCount === 1 ? "" : "s"} armed — a background run may start on the owner's keys. Disable with: exodus workflow triggers <id> disable <n>`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
201
454
|
if (result.unresolved.length > 0) {
|
|
202
455
|
lines.push("");
|
|
203
456
|
lines.push(`Unresolved references (${result.unresolved.length}):`);
|
|
@@ -230,11 +483,12 @@ export function formatWorkflowRun(run) {
|
|
|
230
483
|
const inputs = Object.entries(run.inputs).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
231
484
|
lines.push(`inputs: ${inputs}`);
|
|
232
485
|
}
|
|
486
|
+
const parkedNodeId = gateParkedNodeId(run.status, run.pauseReason, run.pausedNodeId);
|
|
233
487
|
if (run.nodes.length > 0) {
|
|
234
488
|
lines.push("");
|
|
235
489
|
lines.push(`Nodes (${run.nodes.length}):`);
|
|
236
490
|
for (const node of run.nodes) {
|
|
237
|
-
lines.push(progressLine(node));
|
|
491
|
+
lines.push(progressLine(node, parkedNodeId !== undefined && node.nodeId === parkedNodeId));
|
|
238
492
|
for (const output of node.outputs)
|
|
239
493
|
lines.push(...outputLines(output));
|
|
240
494
|
}
|
|
@@ -242,35 +496,44 @@ export function formatWorkflowRun(run) {
|
|
|
242
496
|
if (run.outputs && run.outputs.length > 0) {
|
|
243
497
|
lines.push("");
|
|
244
498
|
lines.push(`Outputs (${run.outputs.length}):`);
|
|
245
|
-
for (const output of run.outputs)
|
|
246
|
-
lines.push(...runOutputLines(output));
|
|
499
|
+
for (const output of run.outputs) {
|
|
500
|
+
lines.push(...runOutputLines(output, parkedNodeId !== undefined && output.nodeId === parkedNodeId));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (run.sessions && run.sessions.length > 0) {
|
|
504
|
+
lines.push("");
|
|
505
|
+
lines.push(`Sessions (${run.sessions.length}):`);
|
|
506
|
+
for (const s of run.sessions) {
|
|
507
|
+
lines.push(` session: ${s.sessionId} · "${s.title}" · continue: exodus session chat ${s.sessionId} "..."`);
|
|
508
|
+
}
|
|
247
509
|
}
|
|
248
510
|
return lines.join("\n");
|
|
249
511
|
}
|
|
250
|
-
function runOutputLines(output) {
|
|
512
|
+
function runOutputLines(output, awaitingReview = false) {
|
|
251
513
|
const slug = output.botSlug ? ` (${output.botSlug})` : "";
|
|
514
|
+
const review = awaitingReview ? " — awaiting review, not yet approved" : "";
|
|
252
515
|
if (output.type === "image") {
|
|
253
|
-
return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}`];
|
|
516
|
+
return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}${review}`];
|
|
254
517
|
}
|
|
255
518
|
if (output.type === "video") {
|
|
256
519
|
const tag = output.final === true ? "final video" : "video";
|
|
257
|
-
return [` ${output.label} [${tag}]${slug}: ${output.videoUrl ?? "(no url)"}`];
|
|
520
|
+
return [` ${output.label} [${tag}]${slug}: ${output.videoUrl ?? "(no url)"}${review}`];
|
|
258
521
|
}
|
|
259
522
|
if (output.type === "audio") {
|
|
260
|
-
return [` ${output.label} [audio]${slug}: ${output.audioUrl ?? "(no url)"}`];
|
|
523
|
+
return [` ${output.label} [audio]${slug}: ${output.audioUrl ?? "(no url)"}${review}`];
|
|
261
524
|
}
|
|
262
525
|
if (output.type === "frames") {
|
|
263
526
|
const n = output.frames?.length ?? 0;
|
|
264
|
-
return [` ${output.label} [frames]${slug}: ${n} scene${n === 1 ? "" : "s"}`];
|
|
527
|
+
return [` ${output.label} [frames]${slug}: ${n} scene${n === 1 ? "" : "s"}${review}`];
|
|
265
528
|
}
|
|
266
529
|
if (output.type === "storyboard") {
|
|
267
|
-
return [` ${output.label} [storyboard]${slug}: use --json for the scene plan`];
|
|
530
|
+
return [` ${output.label} [storyboard]${slug}: use --json for the scene plan${review}`];
|
|
268
531
|
}
|
|
269
532
|
const raw = output.text ?? "";
|
|
270
533
|
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
271
534
|
const body = truncateText(raw, 400);
|
|
272
535
|
const note = normalized.length > 400 ? "\n (truncated — use --json for the full text)" : "";
|
|
273
|
-
return [` ${output.label} [text]${slug}:`, ` ${body}${note}`];
|
|
536
|
+
return [` ${output.label} [text]${slug}${review}:`, ` ${body}${note}`];
|
|
274
537
|
}
|
|
275
538
|
export function formatDescribe(res) {
|
|
276
539
|
const lines = [];
|
|
@@ -335,6 +598,9 @@ function formatBotVocabulary(catalog) {
|
|
|
335
598
|
lines.push(` aspect ratios: ${v.aspectRatios.join(", ")}`);
|
|
336
599
|
lines.push(` custom bot: set a bot node's slug to "${catalog.customBot.slug}" and ` +
|
|
337
600
|
`config.${catalog.customBot.configKey}=<genesis-slug> to reach any bot not listed here.`);
|
|
601
|
+
lines.push(` prompt bot: set a bot node's slug to "${catalog.promptBot.slug}" and ` +
|
|
602
|
+
`config.${catalog.promptBot.configKey}=<your instructions> — the prompt IS the bot ` +
|
|
603
|
+
"(runs on the workspace's own LLM key, not Genesis).");
|
|
338
604
|
return lines.join("\n");
|
|
339
605
|
}
|
|
340
606
|
export function formatBotsList(catalog, category) {
|
|
@@ -370,7 +636,6 @@ export function formatBotDetail(bot) {
|
|
|
370
636
|
lines.push(`category: ${bot.categoryLabel} (${bot.category})`);
|
|
371
637
|
lines.push(`blurb: ${bot.blurb}`);
|
|
372
638
|
lines.push(`outputType: ${bot.outputType}`);
|
|
373
|
-
lines.push(`splits: ${bot.splitsOutput ? "yes (can split into per-item deliverables)" : "no"}`);
|
|
374
639
|
lines.push("");
|
|
375
640
|
lines.push(`Input ports (${bot.inputs.length}):`);
|
|
376
641
|
if (bot.inputs.length === 0) {
|
|
@@ -412,6 +677,161 @@ export function formatBotDetail(bot) {
|
|
|
412
677
|
}
|
|
413
678
|
return lines.join("\n");
|
|
414
679
|
}
|
|
680
|
+
function firstLine(text) {
|
|
681
|
+
const nl = text.indexOf("\n");
|
|
682
|
+
return (nl === -1 ? text : text.slice(0, nl)).trim();
|
|
683
|
+
}
|
|
684
|
+
export function formatTemplatesList(templates) {
|
|
685
|
+
if (templates.length === 0)
|
|
686
|
+
return "No workflow templates available on this backend.";
|
|
687
|
+
const lines = [
|
|
688
|
+
table(["key", "label", "description"], templates.map((t) => [
|
|
689
|
+
t.key,
|
|
690
|
+
t.module ? `${t.label} [${t.module} module]` : t.label,
|
|
691
|
+
firstLine(t.description ?? ""),
|
|
692
|
+
])),
|
|
693
|
+
];
|
|
694
|
+
if (templates.some((t) => t.module)) {
|
|
695
|
+
lines.push("");
|
|
696
|
+
lines.push("Templates badged [<module> module] are owned by a module (e.g. video) — " +
|
|
697
|
+
"their runs start from the show surfaces, not `workflow run`. Export one to " +
|
|
698
|
+
"study or adapt its graph.");
|
|
699
|
+
}
|
|
700
|
+
lines.push("");
|
|
701
|
+
lines.push("Export one to start authoring: exodus workflow templates export <key> --out my.yaml");
|
|
702
|
+
return lines.join("\n");
|
|
703
|
+
}
|
|
704
|
+
const SCHEMA_LABEL_KEYS = ["kind", "face", "type", "name", "id", "code", "key", "label"];
|
|
705
|
+
function schemaEntryLabel(entry) {
|
|
706
|
+
for (const key of SCHEMA_LABEL_KEYS) {
|
|
707
|
+
const v = entry[key];
|
|
708
|
+
if (typeof v === "string" && v)
|
|
709
|
+
return { key, value: v };
|
|
710
|
+
}
|
|
711
|
+
return undefined;
|
|
712
|
+
}
|
|
713
|
+
function schemaValueLines(value, indent) {
|
|
714
|
+
if (value === null || value === undefined)
|
|
715
|
+
return [`${indent}(none)`];
|
|
716
|
+
if (Array.isArray(value)) {
|
|
717
|
+
if (value.length === 0)
|
|
718
|
+
return [`${indent}(none)`];
|
|
719
|
+
const lines = [];
|
|
720
|
+
for (const el of value) {
|
|
721
|
+
if (isRecord(el)) {
|
|
722
|
+
const label = schemaEntryLabel(el);
|
|
723
|
+
if (label) {
|
|
724
|
+
lines.push(`${indent}- ${label.value}`);
|
|
725
|
+
const rest = { ...el };
|
|
726
|
+
delete rest[label.key];
|
|
727
|
+
lines.push(...schemaValueLines(rest, `${indent} `));
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
lines.push(`${indent}-`);
|
|
731
|
+
lines.push(...schemaValueLines(el, `${indent} `));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
lines.push(`${indent}- ${String(el)}`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return lines;
|
|
739
|
+
}
|
|
740
|
+
if (isRecord(value)) {
|
|
741
|
+
const entries = Object.entries(value);
|
|
742
|
+
if (entries.length === 0)
|
|
743
|
+
return [`${indent}(none)`];
|
|
744
|
+
const lines = [];
|
|
745
|
+
for (const [k, v] of entries) {
|
|
746
|
+
if (isRecord(v) || Array.isArray(v)) {
|
|
747
|
+
lines.push(`${indent}${k}:`);
|
|
748
|
+
lines.push(...schemaValueLines(v, `${indent} `));
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
lines.push(`${indent}${k}: ${String(v)}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return lines;
|
|
755
|
+
}
|
|
756
|
+
return [`${indent}${String(value)}`];
|
|
757
|
+
}
|
|
758
|
+
const SCHEMA_SECTIONS = [
|
|
759
|
+
{ title: "Graph contract version", keys: ["graphVersion"] },
|
|
760
|
+
{ title: "Port types", keys: ["portTypes"] },
|
|
761
|
+
{ title: "Node kinds", keys: ["nodeKinds"] },
|
|
762
|
+
{ title: "Transform faces", keys: ["transformFaces"] },
|
|
763
|
+
{ title: "Gate policies", keys: ["gatePolicies"] },
|
|
764
|
+
{ title: "Collector policy", keys: ["collectorPolicy"] },
|
|
765
|
+
{ title: "Deposits", keys: ["deposits"] },
|
|
766
|
+
{ title: "Slots", keys: ["slots"] },
|
|
767
|
+
{ title: "Triggers", keys: ["triggers"] },
|
|
768
|
+
{ title: "Edge / graph rules", keys: ["edgeRules", "graphRules"] },
|
|
769
|
+
{
|
|
770
|
+
title: "Bots",
|
|
771
|
+
keys: ["botsPointer", "botsPointerNote", "bots-pointer-note", "botsNote", "bots"],
|
|
772
|
+
},
|
|
773
|
+
];
|
|
774
|
+
function schemaSectionValue(payload, keys) {
|
|
775
|
+
for (const key of keys) {
|
|
776
|
+
if (payload[key] !== undefined)
|
|
777
|
+
return { key, value: payload[key] };
|
|
778
|
+
}
|
|
779
|
+
return undefined;
|
|
780
|
+
}
|
|
781
|
+
export function formatSchema(payload) {
|
|
782
|
+
const lines = [];
|
|
783
|
+
const version = payload["version"];
|
|
784
|
+
lines.push(`Workflow schema${version !== undefined ? ` (version ${version})` : ""}`);
|
|
785
|
+
for (const section of SCHEMA_SECTIONS) {
|
|
786
|
+
const found = schemaSectionValue(payload, section.keys);
|
|
787
|
+
if (!found)
|
|
788
|
+
continue;
|
|
789
|
+
lines.push("");
|
|
790
|
+
lines.push(`${section.title}:`);
|
|
791
|
+
lines.push(...schemaValueLines(found.value, " "));
|
|
792
|
+
}
|
|
793
|
+
return lines.join("\n");
|
|
794
|
+
}
|
|
795
|
+
function schemaEntryIds(value) {
|
|
796
|
+
if (!Array.isArray(value))
|
|
797
|
+
return [];
|
|
798
|
+
const ids = [];
|
|
799
|
+
for (const el of value) {
|
|
800
|
+
if (isRecord(el)) {
|
|
801
|
+
const label = schemaEntryLabel(el);
|
|
802
|
+
if (label)
|
|
803
|
+
ids.push(label.value);
|
|
804
|
+
}
|
|
805
|
+
else if (typeof el === "string") {
|
|
806
|
+
ids.push(el);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return ids;
|
|
810
|
+
}
|
|
811
|
+
function formatSchemaFilter(payload, sectionKeys, axis, wanted) {
|
|
812
|
+
const found = schemaSectionValue(payload, sectionKeys);
|
|
813
|
+
const list = found && Array.isArray(found.value) ? found.value : [];
|
|
814
|
+
const match = list.find((el) => {
|
|
815
|
+
if (!isRecord(el))
|
|
816
|
+
return typeof el === "string" && el === wanted;
|
|
817
|
+
const label = schemaEntryLabel(el);
|
|
818
|
+
return label?.value === wanted;
|
|
819
|
+
});
|
|
820
|
+
if (match === undefined) {
|
|
821
|
+
const valid = schemaEntryIds(found?.value).join(", ") || "(none)";
|
|
822
|
+
return { code: 1, lines: [`Unknown ${axis} "${wanted}". Valid ${axis}s: ${valid}`] };
|
|
823
|
+
}
|
|
824
|
+
const title = axis === "kind" ? "Node kind" : "Transform face";
|
|
825
|
+
const lines = [`${title}: ${wanted}`];
|
|
826
|
+
if (isRecord(match)) {
|
|
827
|
+
const label = schemaEntryLabel(match);
|
|
828
|
+
const rest = { ...match };
|
|
829
|
+
if (label)
|
|
830
|
+
delete rest[label.key];
|
|
831
|
+
lines.push(...schemaValueLines(rest, " "));
|
|
832
|
+
}
|
|
833
|
+
return { code: 0, lines };
|
|
834
|
+
}
|
|
415
835
|
function graphIssueLines(issue) {
|
|
416
836
|
const node = issue.nodeId ? ` [node ${issue.nodeId}]` : "";
|
|
417
837
|
const port = issue.portId ? ` [port ${issue.portId}]` : "";
|
|
@@ -448,15 +868,31 @@ function formatImportError(res) {
|
|
|
448
868
|
lines.push(`fix: ${remedy}`);
|
|
449
869
|
return lines.join("\n");
|
|
450
870
|
}
|
|
451
|
-
return
|
|
871
|
+
return formatApiError(res);
|
|
872
|
+
}
|
|
873
|
+
class WorkflowResolveError extends Error {
|
|
874
|
+
status;
|
|
875
|
+
data;
|
|
876
|
+
constructor(status, data, message) {
|
|
877
|
+
super(message);
|
|
878
|
+
this.status = status;
|
|
879
|
+
this.data = data;
|
|
880
|
+
this.name = "WorkflowResolveError";
|
|
881
|
+
}
|
|
452
882
|
}
|
|
453
883
|
export async function resolveWorkflowId(ref, deps) {
|
|
454
884
|
const res = await deps.get(LIST_PATH);
|
|
455
885
|
if (!res.ok)
|
|
456
|
-
throw new
|
|
886
|
+
throw new WorkflowResolveError(res.status, res.data, formatApiError(res));
|
|
457
887
|
const workflows = res.data.workflows ?? [];
|
|
458
|
-
const
|
|
459
|
-
|
|
888
|
+
const byName = workflows.find((w) => w.name.toLowerCase() === ref.toLowerCase());
|
|
889
|
+
if (byName)
|
|
890
|
+
return byName._id;
|
|
891
|
+
const byId = workflows.find((w) => w._id === ref);
|
|
892
|
+
if (byId)
|
|
893
|
+
return byId._id;
|
|
894
|
+
const message = `No workflow named "${ref}" on this brand — run: exodus workflow list`;
|
|
895
|
+
throw new WorkflowResolveError(404, { error: { code: "NOT_FOUND", message } }, message);
|
|
460
896
|
}
|
|
461
897
|
export async function listFlow(json, deps) {
|
|
462
898
|
const res = await deps.get(LIST_PATH);
|
|
@@ -474,7 +910,7 @@ export async function describeFlow(workflowRef, opts, deps) {
|
|
|
474
910
|
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
475
911
|
}
|
|
476
912
|
catch (e) {
|
|
477
|
-
return
|
|
913
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
478
914
|
}
|
|
479
915
|
const res = await deps.get(`${DESCRIBE_PATH}?id=${encodeURIComponent(workflowId)}`);
|
|
480
916
|
if (!res.ok)
|
|
@@ -508,11 +944,14 @@ export async function runFlow(workflowRef, opts, deps) {
|
|
|
508
944
|
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
509
945
|
}
|
|
510
946
|
catch (e) {
|
|
511
|
-
return
|
|
947
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
512
948
|
}
|
|
513
949
|
const body = {
|
|
514
950
|
workflowId,
|
|
515
951
|
...(Object.keys(opts.inputs).length > 0 ? { inputs: opts.inputs } : {}),
|
|
952
|
+
...(opts.terminalNodeIds && opts.terminalNodeIds.length > 0
|
|
953
|
+
? { terminalNodeIds: opts.terminalNodeIds }
|
|
954
|
+
: {}),
|
|
516
955
|
};
|
|
517
956
|
const start = await deps.post(RUN_PATH, body);
|
|
518
957
|
if (!start.ok)
|
|
@@ -536,10 +975,16 @@ export async function runFlow(workflowRef, opts, deps) {
|
|
|
536
975
|
opts.onProgressLine(line);
|
|
537
976
|
lines.length = 0;
|
|
538
977
|
}
|
|
978
|
+
const waited = await waitForRun(data.runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base }, deps);
|
|
979
|
+
if (opts.json)
|
|
980
|
+
return waited;
|
|
981
|
+
return { code: waited.code, lines: [...lines, ...waited.lines] };
|
|
982
|
+
}
|
|
983
|
+
async function waitForRun(runId, opts, deps) {
|
|
539
984
|
const seen = new Map();
|
|
540
985
|
let pausedNotified = false;
|
|
541
986
|
const pollResult = await deps.poll({
|
|
542
|
-
path: `${STATUS_PATH}?runId=${encodeURIComponent(
|
|
987
|
+
path: `${STATUS_PATH}?runId=${encodeURIComponent(runId)}`,
|
|
543
988
|
intervalMs: 3_000,
|
|
544
989
|
timeoutMs: 60 * 60 * 1000,
|
|
545
990
|
terminalStatuses: ["completed", "partial", "failed", "canceled"],
|
|
@@ -548,28 +993,39 @@ export async function runFlow(workflowRef, opts, deps) {
|
|
|
548
993
|
return;
|
|
549
994
|
if (raw["status"] === "awaiting-review" && !pausedNotified) {
|
|
550
995
|
pausedNotified = true;
|
|
551
|
-
|
|
996
|
+
const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
|
|
997
|
+
const pauseReason = raw["pauseReason"];
|
|
998
|
+
for (const line of formatPauseNotice(pauseReason, runId, dashboardUrl)) {
|
|
999
|
+
opts.onProgressLine(line);
|
|
1000
|
+
}
|
|
552
1001
|
}
|
|
1002
|
+
const parkedNodeId = gateParkedNodeId(raw["status"], raw["pauseReason"], raw["pausedNodeId"]);
|
|
553
1003
|
const nodes = Array.isArray(raw["nodes"]) ? raw["nodes"] : [];
|
|
554
1004
|
for (const node of nodes) {
|
|
555
|
-
|
|
1005
|
+
const isParked = parkedNodeId !== undefined && node.nodeId === parkedNodeId;
|
|
1006
|
+
const renderKey = `${node.status}:${isParked}`;
|
|
1007
|
+
if (seen.get(node.nodeId) === renderKey)
|
|
556
1008
|
continue;
|
|
557
|
-
seen.set(node.nodeId,
|
|
558
|
-
opts.onProgressLine(progressLine(node));
|
|
1009
|
+
seen.set(node.nodeId, renderKey);
|
|
1010
|
+
opts.onProgressLine(progressLine(node, isParked));
|
|
559
1011
|
}
|
|
560
1012
|
},
|
|
561
1013
|
});
|
|
562
1014
|
if (opts.json) {
|
|
563
1015
|
return {
|
|
564
1016
|
code: pollResult.ok ? 0 : 1,
|
|
565
|
-
lines: [
|
|
1017
|
+
lines: [
|
|
1018
|
+
JSON.stringify({ ...opts.jsonBase, result: pollResult.data, timedOut: pollResult.timedOut }),
|
|
1019
|
+
],
|
|
566
1020
|
};
|
|
567
1021
|
}
|
|
568
1022
|
if (pollResult.timedOut) {
|
|
569
|
-
|
|
570
|
-
|
|
1023
|
+
return {
|
|
1024
|
+
code: 1,
|
|
1025
|
+
lines: [`Timed out waiting. Check later: exodus workflow status --id ${runId}`],
|
|
1026
|
+
};
|
|
571
1027
|
}
|
|
572
|
-
lines
|
|
1028
|
+
const lines = [""];
|
|
573
1029
|
if (isRecord(pollResult.data) && typeof pollResult.data["_id"] === "string") {
|
|
574
1030
|
lines.push(formatWorkflowRun(pollResult.data));
|
|
575
1031
|
}
|
|
@@ -596,36 +1052,81 @@ export async function exportFlow(workflowRef, opts, deps) {
|
|
|
596
1052
|
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
597
1053
|
}
|
|
598
1054
|
catch (e) {
|
|
599
|
-
return
|
|
1055
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
600
1056
|
}
|
|
601
|
-
const
|
|
1057
|
+
const versionParam = opts.version !== undefined ? `&version=${encodeURIComponent(String(opts.version))}` : "";
|
|
1058
|
+
const res = await deps.get(`${EXPORT_PATH}?id=${encodeURIComponent(workflowId)}${versionParam}`);
|
|
602
1059
|
if (!res.ok)
|
|
603
1060
|
return asErrorResult(res, false);
|
|
604
|
-
const doc =
|
|
1061
|
+
const doc = opts.json
|
|
1062
|
+
? JSON.stringify(res.data, null, 2)
|
|
1063
|
+
: workflowToYaml(res.data);
|
|
605
1064
|
if (opts.out) {
|
|
606
|
-
|
|
1065
|
+
const text = doc.endsWith("\n") ? doc : `${doc}\n`;
|
|
1066
|
+
deps.writeFile(opts.out, text);
|
|
607
1067
|
return { code: 0, lines: [`Wrote workflow contract to ${opts.out}.`] };
|
|
608
1068
|
}
|
|
609
|
-
return { code: 0, lines: [doc] };
|
|
1069
|
+
return { code: 0, lines: [doc.endsWith("\n") ? doc.slice(0, -1) : doc] };
|
|
610
1070
|
}
|
|
611
|
-
export
|
|
1071
|
+
export function parseVersionFlag(flags) {
|
|
1072
|
+
const raw = flags["version"];
|
|
1073
|
+
if (raw === undefined)
|
|
1074
|
+
return undefined;
|
|
1075
|
+
if (typeof raw !== "string")
|
|
1076
|
+
throw new Error("--version requires a positive integer");
|
|
1077
|
+
const n = Number(raw);
|
|
1078
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
1079
|
+
throw new Error(`--version must be a positive integer (got "${raw}")`);
|
|
1080
|
+
}
|
|
1081
|
+
return n;
|
|
1082
|
+
}
|
|
1083
|
+
export async function versionsFlow(workflowRef, opts, deps, channel = getChannel()) {
|
|
1084
|
+
let workflowId;
|
|
1085
|
+
try {
|
|
1086
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
1087
|
+
}
|
|
1088
|
+
catch (e) {
|
|
1089
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
1090
|
+
}
|
|
1091
|
+
const res = await deps.get(`${VERSIONS_PATH}?id=${encodeURIComponent(workflowId)}`);
|
|
1092
|
+
const unsupported = missingRouteLine(res, "workflow versions", channel);
|
|
1093
|
+
if (unsupported)
|
|
1094
|
+
return { code: 1, lines: [unsupported] };
|
|
1095
|
+
if (!res.ok)
|
|
1096
|
+
return asErrorResult(res, opts.json);
|
|
1097
|
+
if (opts.json)
|
|
1098
|
+
return { code: 0, lines: [JSON.stringify(res.data)] };
|
|
1099
|
+
const body = res.data;
|
|
1100
|
+
const versions = Array.isArray(body)
|
|
1101
|
+
? body
|
|
1102
|
+
: (body.versions ?? []);
|
|
1103
|
+
return { code: 0, lines: [formatWorkflowVersions(versions)] };
|
|
1104
|
+
}
|
|
1105
|
+
function buildImportBody(file, opts, deps) {
|
|
612
1106
|
let text;
|
|
613
1107
|
try {
|
|
614
1108
|
text = deps.readFile(file);
|
|
615
1109
|
}
|
|
616
1110
|
catch {
|
|
617
|
-
return { code: 1, lines: [`Error: file not found: ${file}`] };
|
|
1111
|
+
return { error: { code: 1, lines: [`Error: file not found: ${file}`] } };
|
|
618
1112
|
}
|
|
619
1113
|
let parsed;
|
|
620
1114
|
try {
|
|
621
|
-
parsed =
|
|
1115
|
+
parsed = parseWorkflowText(text);
|
|
622
1116
|
}
|
|
623
1117
|
catch (e) {
|
|
624
|
-
const
|
|
625
|
-
|
|
1118
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
1119
|
+
const detail = raw.replace(/^workflow file is not valid YAML or JSON:?\s*/, "");
|
|
1120
|
+
const suffix = detail ? `: ${detail}` : "";
|
|
1121
|
+
return { error: { code: 1, lines: [`Error: ${file} is not valid YAML or JSON${suffix}`] } };
|
|
626
1122
|
}
|
|
627
1123
|
if (!isRecord(parsed)) {
|
|
628
|
-
return {
|
|
1124
|
+
return {
|
|
1125
|
+
error: {
|
|
1126
|
+
code: 1,
|
|
1127
|
+
lines: [`Error: ${file} is not a workflow contract (expected a JSON object).`],
|
|
1128
|
+
},
|
|
1129
|
+
};
|
|
629
1130
|
}
|
|
630
1131
|
const body = { ...parsed };
|
|
631
1132
|
delete body.dryRun;
|
|
@@ -637,17 +1138,25 @@ export async function importFlow(file, opts, deps) {
|
|
|
637
1138
|
const updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined;
|
|
638
1139
|
if (!updatedAt) {
|
|
639
1140
|
return {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
`
|
|
644
|
-
|
|
1141
|
+
error: {
|
|
1142
|
+
code: 1,
|
|
1143
|
+
lines: [
|
|
1144
|
+
`Error: ${file} has no "updatedAt" anchor, so drift can't be detected. ` +
|
|
1145
|
+
`Re-export the workflow first (exodus workflow export <id> --out ${file}) and retry --update.`,
|
|
1146
|
+
],
|
|
1147
|
+
},
|
|
645
1148
|
};
|
|
646
1149
|
}
|
|
647
1150
|
body.targetWorkflowId = opts.update;
|
|
648
1151
|
body.expectedUpdatedAt = updatedAt;
|
|
649
1152
|
}
|
|
650
|
-
|
|
1153
|
+
return { body };
|
|
1154
|
+
}
|
|
1155
|
+
async function runImport(file, opts, deps) {
|
|
1156
|
+
const built = buildImportBody(file, { dryRun: opts.dryRun, update: opts.update }, deps);
|
|
1157
|
+
if ("error" in built)
|
|
1158
|
+
return built.error;
|
|
1159
|
+
const res = await deps.post(IMPORT_PATH, built.body);
|
|
651
1160
|
if (!res.ok) {
|
|
652
1161
|
return {
|
|
653
1162
|
code: 1,
|
|
@@ -661,9 +1170,684 @@ export async function importFlow(file, opts, deps) {
|
|
|
661
1170
|
code: 0,
|
|
662
1171
|
lines: opts.json
|
|
663
1172
|
? [JSON.stringify(data)]
|
|
664
|
-
: [
|
|
1173
|
+
: [
|
|
1174
|
+
formatImportSummary(data, {
|
|
1175
|
+
dryRun: opts.dryRun,
|
|
1176
|
+
update: !!opts.update,
|
|
1177
|
+
validate: opts.validate,
|
|
1178
|
+
}),
|
|
1179
|
+
],
|
|
665
1180
|
};
|
|
666
1181
|
}
|
|
1182
|
+
export function triggerExpect(t) {
|
|
1183
|
+
return t.type === "event"
|
|
1184
|
+
? { type: "event", event: t.event }
|
|
1185
|
+
: { type: "cron", cron: t.cron };
|
|
1186
|
+
}
|
|
1187
|
+
function triggerDetail(t) {
|
|
1188
|
+
return t.type === "event" ? t.event : t.cron;
|
|
1189
|
+
}
|
|
1190
|
+
const NO_TRIGGERS = "no triggers — add them via `exodus workflow export` / `import`";
|
|
1191
|
+
export function formatTriggers(triggers) {
|
|
1192
|
+
if (triggers.length === 0)
|
|
1193
|
+
return NO_TRIGGERS;
|
|
1194
|
+
return triggers
|
|
1195
|
+
.map((t, i) => {
|
|
1196
|
+
const state = t.enabled ? "enabled" : "disabled";
|
|
1197
|
+
return `${i + 1} · ${t.type} · ${triggerDetail(t)} · ${state}`;
|
|
1198
|
+
})
|
|
1199
|
+
.join("\n");
|
|
1200
|
+
}
|
|
1201
|
+
function triggerErrorResult(res, verb, json) {
|
|
1202
|
+
const missing = missingRouteLine(res, verb);
|
|
1203
|
+
if (missing) {
|
|
1204
|
+
return {
|
|
1205
|
+
code: 1,
|
|
1206
|
+
lines: json
|
|
1207
|
+
? [JSON.stringify({ ok: false, status: res.status, error: missing })]
|
|
1208
|
+
: [missing],
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
return asErrorResult(res, json);
|
|
1212
|
+
}
|
|
1213
|
+
async function fetchTriggers(workflowId, deps) {
|
|
1214
|
+
const res = await deps.get(`${EXPORT_PATH}?id=${encodeURIComponent(workflowId)}`);
|
|
1215
|
+
if (!res.ok)
|
|
1216
|
+
return { ok: false, res };
|
|
1217
|
+
const triggers = res.data.triggers ?? [];
|
|
1218
|
+
return { ok: true, triggers };
|
|
1219
|
+
}
|
|
1220
|
+
export async function triggersListFlow(workflowRef, opts, deps) {
|
|
1221
|
+
let workflowId;
|
|
1222
|
+
try {
|
|
1223
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
1224
|
+
}
|
|
1225
|
+
catch (e) {
|
|
1226
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
1227
|
+
}
|
|
1228
|
+
const fetched = await fetchTriggers(workflowId, deps);
|
|
1229
|
+
if (!fetched.ok)
|
|
1230
|
+
return triggerErrorResult(fetched.res, "workflow triggers", opts.json);
|
|
1231
|
+
if (opts.json) {
|
|
1232
|
+
return {
|
|
1233
|
+
code: 0,
|
|
1234
|
+
lines: [JSON.stringify(fetched.triggers.map((t, i) => ({ n: i + 1, ...t })))],
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
return { code: 0, lines: [formatTriggers(fetched.triggers)] };
|
|
1238
|
+
}
|
|
1239
|
+
function triggerIndexError(message, triggers, json) {
|
|
1240
|
+
if (json) {
|
|
1241
|
+
return {
|
|
1242
|
+
code: 1,
|
|
1243
|
+
lines: [JSON.stringify({ ok: false, error: message, triggers: triggers.map((t, i) => ({ n: i + 1, ...t })) })],
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
return { code: 1, lines: [message, "", formatTriggers(triggers)] };
|
|
1247
|
+
}
|
|
1248
|
+
export async function triggersSetEnabledFlow(workflowRef, n, enabled, opts, deps) {
|
|
1249
|
+
const verb = `workflow triggers ${enabled ? "enable" : "disable"}`;
|
|
1250
|
+
let workflowId;
|
|
1251
|
+
try {
|
|
1252
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
1253
|
+
}
|
|
1254
|
+
catch (e) {
|
|
1255
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
1256
|
+
}
|
|
1257
|
+
const fetched = await fetchTriggers(workflowId, deps);
|
|
1258
|
+
if (!fetched.ok)
|
|
1259
|
+
return triggerErrorResult(fetched.res, verb, opts.json);
|
|
1260
|
+
const triggers = fetched.triggers;
|
|
1261
|
+
const idx = n - 1;
|
|
1262
|
+
if (idx < 0 || idx >= triggers.length) {
|
|
1263
|
+
return triggerIndexError(`Trigger ${n} is out of range — this workflow has ${triggers.length} trigger(s).`, triggers, opts.json);
|
|
1264
|
+
}
|
|
1265
|
+
const res = await deps.post(TRIGGERS_SET_ENABLED_PATH, {
|
|
1266
|
+
workflowId,
|
|
1267
|
+
triggerIndex: idx,
|
|
1268
|
+
enabled,
|
|
1269
|
+
expect: triggerExpect(triggers[idx]),
|
|
1270
|
+
});
|
|
1271
|
+
if (!res.ok)
|
|
1272
|
+
return triggerErrorResult(res, verb, opts.json);
|
|
1273
|
+
if (opts.json)
|
|
1274
|
+
return { code: 0, lines: [JSON.stringify(res.data)] };
|
|
1275
|
+
return { code: 0, lines: [`Trigger ${n} ${enabled ? "enabled" : "disabled"}.`] };
|
|
1276
|
+
}
|
|
1277
|
+
export async function triggersFireFlow(workflowRef, opts, deps) {
|
|
1278
|
+
const verb = "workflow triggers fire";
|
|
1279
|
+
let workflowId;
|
|
1280
|
+
try {
|
|
1281
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
1282
|
+
}
|
|
1283
|
+
catch (e) {
|
|
1284
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
1285
|
+
}
|
|
1286
|
+
const fetched = await fetchTriggers(workflowId, deps);
|
|
1287
|
+
if (!fetched.ok)
|
|
1288
|
+
return triggerErrorResult(fetched.res, verb, opts.json);
|
|
1289
|
+
const triggers = fetched.triggers;
|
|
1290
|
+
if (triggers.length === 0) {
|
|
1291
|
+
return { code: 1, lines: [`This workflow has no triggers. ${NO_TRIGGERS}`] };
|
|
1292
|
+
}
|
|
1293
|
+
let idx;
|
|
1294
|
+
if (opts.n !== undefined) {
|
|
1295
|
+
idx = opts.n - 1;
|
|
1296
|
+
if (idx < 0 || idx >= triggers.length) {
|
|
1297
|
+
return triggerIndexError(`Trigger ${opts.n} is out of range — this workflow has ${triggers.length} trigger(s).`, triggers, opts.json);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
else if (triggers.length === 1) {
|
|
1301
|
+
idx = 0;
|
|
1302
|
+
}
|
|
1303
|
+
else {
|
|
1304
|
+
return triggerIndexError(`This workflow has ${triggers.length} triggers — specify which one to fire (e.g. \`fire 1\`).`, triggers, opts.json);
|
|
1305
|
+
}
|
|
1306
|
+
const t = triggers[idx];
|
|
1307
|
+
const n = idx + 1;
|
|
1308
|
+
if (t.type === "event" && (opts.text === undefined || opts.text === "")) {
|
|
1309
|
+
return {
|
|
1310
|
+
code: 1,
|
|
1311
|
+
lines: [
|
|
1312
|
+
`Trigger ${n} fires on the "${t.event}" event — pass --text with the input this run should carry.`,
|
|
1313
|
+
],
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
if (t.type === "cron" && opts.text !== undefined) {
|
|
1317
|
+
return {
|
|
1318
|
+
code: 1,
|
|
1319
|
+
lines: [`Trigger ${n} is a cron trigger ("${t.cron}") — it takes no --text.`],
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
const res = await deps.post(TRIGGERS_FIRE_PATH, {
|
|
1323
|
+
workflowId,
|
|
1324
|
+
triggerIndex: idx,
|
|
1325
|
+
expect: triggerExpect(t),
|
|
1326
|
+
...(opts.text !== undefined ? { text: opts.text } : {}),
|
|
1327
|
+
});
|
|
1328
|
+
if (!res.ok)
|
|
1329
|
+
return triggerErrorResult(res, verb, opts.json);
|
|
1330
|
+
const runId = res.data.runId;
|
|
1331
|
+
const base = { runId, workflowId };
|
|
1332
|
+
if (!opts.wait) {
|
|
1333
|
+
if (opts.json)
|
|
1334
|
+
return { code: 0, lines: [JSON.stringify(base)] };
|
|
1335
|
+
return {
|
|
1336
|
+
code: 0,
|
|
1337
|
+
lines: [
|
|
1338
|
+
`Trigger ${n} fired.`,
|
|
1339
|
+
`runId: ${runId}`,
|
|
1340
|
+
"This run executes as the workflow OWNER in the background.",
|
|
1341
|
+
`Poll: exodus workflow status --id ${runId}`,
|
|
1342
|
+
],
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
const startLines = [
|
|
1346
|
+
`Trigger ${n} fired.`,
|
|
1347
|
+
`runId: ${runId}`,
|
|
1348
|
+
"This run executes as the workflow OWNER in the background.",
|
|
1349
|
+
`Poll: exodus workflow status --id ${runId}`,
|
|
1350
|
+
];
|
|
1351
|
+
if (!opts.json && opts.onProgressLine) {
|
|
1352
|
+
for (const line of startLines)
|
|
1353
|
+
opts.onProgressLine(line);
|
|
1354
|
+
}
|
|
1355
|
+
const waited = await waitForRun(runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base }, deps);
|
|
1356
|
+
if (opts.json)
|
|
1357
|
+
return waited;
|
|
1358
|
+
const prefix = opts.onProgressLine ? [] : startLines;
|
|
1359
|
+
return { code: waited.code, lines: [...prefix, ...waited.lines] };
|
|
1360
|
+
}
|
|
1361
|
+
export async function importFlow(file, opts, deps) {
|
|
1362
|
+
return runImport(file, opts, deps);
|
|
1363
|
+
}
|
|
1364
|
+
export async function validateFlow(file, opts, deps) {
|
|
1365
|
+
return runImport(file, { dryRun: true, json: opts.json, update: opts.update, validate: true }, deps);
|
|
1366
|
+
}
|
|
1367
|
+
export async function templatesListFlow(json, deps) {
|
|
1368
|
+
const res = await deps.get(TEMPLATES_PATH);
|
|
1369
|
+
if (!res.ok) {
|
|
1370
|
+
const missing = missingRouteLine(res, "workflow templates");
|
|
1371
|
+
if (missing)
|
|
1372
|
+
return { code: 1, lines: [missing] };
|
|
1373
|
+
return asErrorResult(res, json);
|
|
1374
|
+
}
|
|
1375
|
+
const data = res.data;
|
|
1376
|
+
if (json)
|
|
1377
|
+
return { code: 0, lines: [JSON.stringify(data)] };
|
|
1378
|
+
return { code: 0, lines: [formatTemplatesList(data.templates ?? [])] };
|
|
1379
|
+
}
|
|
1380
|
+
export async function templatesExportFlow(key, opts, deps) {
|
|
1381
|
+
const query = opts.json
|
|
1382
|
+
? `?key=${encodeURIComponent(key)}&format=json`
|
|
1383
|
+
: `?key=${encodeURIComponent(key)}`;
|
|
1384
|
+
const res = await deps.getText(`${TEMPLATES_PATH}${query}`);
|
|
1385
|
+
if (!res.ok) {
|
|
1386
|
+
let parsedBody = res.data;
|
|
1387
|
+
try {
|
|
1388
|
+
parsedBody = JSON.parse(res.data);
|
|
1389
|
+
}
|
|
1390
|
+
catch {
|
|
1391
|
+
}
|
|
1392
|
+
const missing = missingRouteLine({ ...res, data: parsedBody }, "workflow templates export");
|
|
1393
|
+
if (missing)
|
|
1394
|
+
return { code: 1, lines: [missing] };
|
|
1395
|
+
return { code: 1, lines: [formatTextError(res)] };
|
|
1396
|
+
}
|
|
1397
|
+
const doc = res.data;
|
|
1398
|
+
if (opts.out) {
|
|
1399
|
+
const text = doc.endsWith("\n") ? doc : `${doc}\n`;
|
|
1400
|
+
deps.writeFile(opts.out, text);
|
|
1401
|
+
return { code: 0, lines: [`Wrote template "${key}" to ${opts.out}.`] };
|
|
1402
|
+
}
|
|
1403
|
+
return { code: 0, lines: [doc.endsWith("\n") ? doc.slice(0, -1) : doc] };
|
|
1404
|
+
}
|
|
1405
|
+
function formatTextError(res) {
|
|
1406
|
+
const body = res.data;
|
|
1407
|
+
let parsed;
|
|
1408
|
+
try {
|
|
1409
|
+
parsed = JSON.parse(body);
|
|
1410
|
+
}
|
|
1411
|
+
catch {
|
|
1412
|
+
const snippet = body.replace(/\s+/g, " ").trim().slice(0, 300);
|
|
1413
|
+
return formatApiError({ ok: false, status: res.status, data: snippet || "(empty response)" });
|
|
1414
|
+
}
|
|
1415
|
+
return formatApiError({ ok: false, status: res.status, data: parsed });
|
|
1416
|
+
}
|
|
1417
|
+
export async function schemaFlow(opts, deps) {
|
|
1418
|
+
const res = await deps.get(SCHEMA_PATH);
|
|
1419
|
+
if (!res.ok) {
|
|
1420
|
+
const missing = missingRouteLine(res, "workflow schema");
|
|
1421
|
+
if (missing)
|
|
1422
|
+
return { code: 1, lines: [missing] };
|
|
1423
|
+
return asErrorResult(res, opts.json);
|
|
1424
|
+
}
|
|
1425
|
+
if (opts.json)
|
|
1426
|
+
return { code: 0, lines: [JSON.stringify(res.data)] };
|
|
1427
|
+
const payload = isRecord(res.data) ? res.data : {};
|
|
1428
|
+
if (opts.kind !== undefined) {
|
|
1429
|
+
return formatSchemaFilter(payload, ["nodeKinds"], "kind", opts.kind);
|
|
1430
|
+
}
|
|
1431
|
+
if (opts.face !== undefined) {
|
|
1432
|
+
return formatSchemaFilter(payload, ["transformFaces"], "face", opts.face);
|
|
1433
|
+
}
|
|
1434
|
+
return { code: 0, lines: [formatSchema(payload)] };
|
|
1435
|
+
}
|
|
1436
|
+
export function formatAge(value, now = Date.now()) {
|
|
1437
|
+
if (value === undefined || value === null)
|
|
1438
|
+
return "-";
|
|
1439
|
+
const t = typeof value === "number" ? value : Date.parse(value);
|
|
1440
|
+
if (Number.isNaN(t))
|
|
1441
|
+
return "-";
|
|
1442
|
+
const sec = Math.max(0, Math.floor((now - t) / 1000));
|
|
1443
|
+
if (sec < 60)
|
|
1444
|
+
return `${sec}s`;
|
|
1445
|
+
const min = Math.floor(sec / 60);
|
|
1446
|
+
if (min < 60)
|
|
1447
|
+
return `${min}m`;
|
|
1448
|
+
const hr = Math.floor(min / 60);
|
|
1449
|
+
if (hr < 24)
|
|
1450
|
+
return `${hr}h`;
|
|
1451
|
+
return `${Math.floor(hr / 24)}d`;
|
|
1452
|
+
}
|
|
1453
|
+
export function parkBadge(pauseReason) {
|
|
1454
|
+
if (pauseReason === "taste")
|
|
1455
|
+
return "gate";
|
|
1456
|
+
if (pauseReason === "repair")
|
|
1457
|
+
return "repair";
|
|
1458
|
+
if (pauseReason === "slots")
|
|
1459
|
+
return "slots";
|
|
1460
|
+
return "legacy";
|
|
1461
|
+
}
|
|
1462
|
+
export function invocationBadge(row) {
|
|
1463
|
+
if (row.triggeredBy)
|
|
1464
|
+
return `trig:${row.triggeredBy.event ?? row.triggeredBy.type}`;
|
|
1465
|
+
if (row.invocationMode === "background")
|
|
1466
|
+
return "bg";
|
|
1467
|
+
return "";
|
|
1468
|
+
}
|
|
1469
|
+
const NO_INBOX = "Nothing waiting on you — the review inbox is empty.";
|
|
1470
|
+
export function formatInbox(rows, now = Date.now()) {
|
|
1471
|
+
if (rows.length === 0)
|
|
1472
|
+
return NO_INBOX;
|
|
1473
|
+
return table(["run", "workflow", "kind", "node", "via", "age"], rows.map((r) => [
|
|
1474
|
+
r._id,
|
|
1475
|
+
r.workflowName || "(unnamed)",
|
|
1476
|
+
parkBadge(r.pauseReason),
|
|
1477
|
+
r.pausedNodeId ?? "-",
|
|
1478
|
+
invocationBadge(r) || "-",
|
|
1479
|
+
formatAge(r.createdAt, now),
|
|
1480
|
+
]));
|
|
1481
|
+
}
|
|
1482
|
+
export async function inboxFlow(json, deps) {
|
|
1483
|
+
const res = await deps.get(INBOX_PATH);
|
|
1484
|
+
if (!res.ok)
|
|
1485
|
+
return triggerErrorResult(res, "workflow inbox", json);
|
|
1486
|
+
const data = res.data;
|
|
1487
|
+
const rows = data.runs ?? [];
|
|
1488
|
+
return { code: 0, lines: json ? [JSON.stringify(rows)] : [formatInbox(rows)] };
|
|
1489
|
+
}
|
|
1490
|
+
function errLine(message, json, status) {
|
|
1491
|
+
return {
|
|
1492
|
+
code: 1,
|
|
1493
|
+
lines: [
|
|
1494
|
+
json
|
|
1495
|
+
? JSON.stringify({ ok: false, ...(status ? { status } : {}), error: message })
|
|
1496
|
+
: message,
|
|
1497
|
+
],
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
function okLine(message, payload, json) {
|
|
1501
|
+
return { code: 0, lines: [json ? JSON.stringify(payload) : message] };
|
|
1502
|
+
}
|
|
1503
|
+
function routeErrorText(data, status) {
|
|
1504
|
+
if (isRecord(data)) {
|
|
1505
|
+
const err = data.error;
|
|
1506
|
+
if (typeof err === "string" && err)
|
|
1507
|
+
return err;
|
|
1508
|
+
if (isRecord(err) && typeof err.message === "string")
|
|
1509
|
+
return err.message;
|
|
1510
|
+
if (typeof data.message === "string" && data.message)
|
|
1511
|
+
return data.message;
|
|
1512
|
+
}
|
|
1513
|
+
return `HTTP ${status}`;
|
|
1514
|
+
}
|
|
1515
|
+
function asWorkflowRun(data) {
|
|
1516
|
+
return isRecord(data) && typeof data["_id"] === "string"
|
|
1517
|
+
? data
|
|
1518
|
+
: undefined;
|
|
1519
|
+
}
|
|
1520
|
+
function describePark(run) {
|
|
1521
|
+
if (run.status !== "awaiting-review")
|
|
1522
|
+
return `status: ${run.status}`;
|
|
1523
|
+
switch (run.pauseReason) {
|
|
1524
|
+
case "taste":
|
|
1525
|
+
return "parked at a gate (taste review)";
|
|
1526
|
+
case "repair":
|
|
1527
|
+
return "parked for repair";
|
|
1528
|
+
case "slots":
|
|
1529
|
+
return "parked for slot answers";
|
|
1530
|
+
case "call":
|
|
1531
|
+
return "parked on a child workflow";
|
|
1532
|
+
default:
|
|
1533
|
+
return "parked at the cost gate (legacy — no text gate)";
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const PARK_LABEL = {
|
|
1537
|
+
taste: "a gate review",
|
|
1538
|
+
repair: "a repair",
|
|
1539
|
+
slots: "slot answers",
|
|
1540
|
+
call: "a child workflow",
|
|
1541
|
+
};
|
|
1542
|
+
async function preflightPark(runId, expected, verb, json, deps) {
|
|
1543
|
+
const res = await deps.get(`${STATUS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1544
|
+
if (!res.ok)
|
|
1545
|
+
return { ok: false, result: triggerErrorResult(res, verb, json) };
|
|
1546
|
+
const run = asWorkflowRun(res.data);
|
|
1547
|
+
if (!run) {
|
|
1548
|
+
return { ok: false, result: errLine(`Could not read run ${runId}.`, json) };
|
|
1549
|
+
}
|
|
1550
|
+
if (run.status !== "awaiting-review" || run.pauseReason !== expected) {
|
|
1551
|
+
return {
|
|
1552
|
+
ok: false,
|
|
1553
|
+
result: errLine(`Run ${runId} is not parked for ${PARK_LABEL[expected]} — it is ${describePark(run)}.`, json),
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
return { ok: true, run };
|
|
1557
|
+
}
|
|
1558
|
+
function gateCandidates(run) {
|
|
1559
|
+
const node = (run.nodes ?? []).find((x) => x.nodeId === run.pausedNodeId);
|
|
1560
|
+
if (!node)
|
|
1561
|
+
return [];
|
|
1562
|
+
const out = [];
|
|
1563
|
+
let n = 0;
|
|
1564
|
+
(node.outputs ?? []).forEach((a, idx) => {
|
|
1565
|
+
if (a.type === "text" && a.port === "selection") {
|
|
1566
|
+
n += 1;
|
|
1567
|
+
out.push({ n, outputIndex: idx, text: a.text, humanEdited: !!a.humanEdited });
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
return out;
|
|
1571
|
+
}
|
|
1572
|
+
function formatCandidates(cands) {
|
|
1573
|
+
if (cands.length === 0)
|
|
1574
|
+
return " (this gate has no selection candidates)";
|
|
1575
|
+
return cands
|
|
1576
|
+
.map((c) => ` ${c.n}. ${truncateText(c.text, 200)}${c.humanEdited ? " (edited)" : ""}`)
|
|
1577
|
+
.join("\n");
|
|
1578
|
+
}
|
|
1579
|
+
function rangeError(num, count, json) {
|
|
1580
|
+
const range = count > 0 ? ` (valid: 1–${count})` : "";
|
|
1581
|
+
return errLine(`Candidate ${num} is out of range — this gate has ${count} candidate${count === 1 ? "" : "s"}${range}.`, json);
|
|
1582
|
+
}
|
|
1583
|
+
export async function gateShowFlow(runId, opts, deps) {
|
|
1584
|
+
const pf = await preflightPark(runId, "taste", "workflow gate", opts.json, deps);
|
|
1585
|
+
if (!pf.ok)
|
|
1586
|
+
return pf.result;
|
|
1587
|
+
const cands = gateCandidates(pf.run);
|
|
1588
|
+
if (opts.json) {
|
|
1589
|
+
return {
|
|
1590
|
+
code: 0,
|
|
1591
|
+
lines: [
|
|
1592
|
+
JSON.stringify({
|
|
1593
|
+
runId,
|
|
1594
|
+
pausedNodeId: pf.run.pausedNodeId,
|
|
1595
|
+
candidates: cands.map((c) => ({ n: c.n, text: c.text, humanEdited: c.humanEdited })),
|
|
1596
|
+
}),
|
|
1597
|
+
],
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
return {
|
|
1601
|
+
code: 0,
|
|
1602
|
+
lines: [
|
|
1603
|
+
`Gate — ${pf.run.workflowName}`,
|
|
1604
|
+
`runId: ${runId}`,
|
|
1605
|
+
`gate node: ${pf.run.pausedNodeId ?? "-"}`,
|
|
1606
|
+
"",
|
|
1607
|
+
`Candidates (${cands.length}):`,
|
|
1608
|
+
formatCandidates(cands),
|
|
1609
|
+
"",
|
|
1610
|
+
`Pick: exodus workflow gate ${runId} pick 1,2`,
|
|
1611
|
+
`Edit: exodus workflow gate ${runId} edit 1 --text "..."`,
|
|
1612
|
+
`Approve: exodus workflow gate ${runId} approve --wait`,
|
|
1613
|
+
`Reject: exodus workflow gate ${runId} reject --reason "..."`,
|
|
1614
|
+
],
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
export async function gatePickFlow(runId, numbers, opts, deps) {
|
|
1618
|
+
const pf = await preflightPark(runId, "taste", "workflow gate pick", opts.json, deps);
|
|
1619
|
+
if (!pf.ok)
|
|
1620
|
+
return pf.result;
|
|
1621
|
+
const cands = gateCandidates(pf.run);
|
|
1622
|
+
const seen = new Set();
|
|
1623
|
+
for (const num of numbers) {
|
|
1624
|
+
if (!Number.isInteger(num) || num < 1 || num > cands.length) {
|
|
1625
|
+
return rangeError(num, cands.length, opts.json);
|
|
1626
|
+
}
|
|
1627
|
+
if (seen.has(num))
|
|
1628
|
+
return errLine(`Candidate ${num} is listed twice.`, opts.json);
|
|
1629
|
+
seen.add(num);
|
|
1630
|
+
}
|
|
1631
|
+
const selectedIndices = numbers.map((num) => num - 1);
|
|
1632
|
+
const res = await deps.post(GATE_PICK_PATH, {
|
|
1633
|
+
runId,
|
|
1634
|
+
nodeId: pf.run.pausedNodeId,
|
|
1635
|
+
selectedIndices,
|
|
1636
|
+
});
|
|
1637
|
+
if (!res.ok)
|
|
1638
|
+
return triggerErrorResult(res, "workflow gate pick", opts.json);
|
|
1639
|
+
return okLine(`Picked candidate${numbers.length === 1 ? "" : "s"} ${numbers.join(", ")} at ${pf.run.pausedNodeId}. Approve to resume: exodus workflow gate ${runId} approve --wait`, { ok: true, runId, nodeId: pf.run.pausedNodeId, selectedIndices }, opts.json);
|
|
1640
|
+
}
|
|
1641
|
+
export async function gateEditFlow(runId, n, sources, opts, deps) {
|
|
1642
|
+
const provided = [sources.text, sources.file, sources.stdin].filter((x) => x !== undefined);
|
|
1643
|
+
if (provided.length === 0) {
|
|
1644
|
+
return errLine("Provide the replacement text via one of --text, --file <path>, or piped stdin.", opts.json);
|
|
1645
|
+
}
|
|
1646
|
+
if (provided.length > 1) {
|
|
1647
|
+
return errLine("Provide the replacement text via exactly one of --text, --file, or stdin — not several.", opts.json);
|
|
1648
|
+
}
|
|
1649
|
+
let text;
|
|
1650
|
+
if (sources.text !== undefined) {
|
|
1651
|
+
text = sources.text;
|
|
1652
|
+
}
|
|
1653
|
+
else if (sources.file !== undefined) {
|
|
1654
|
+
try {
|
|
1655
|
+
text = deps.readFile(sources.file);
|
|
1656
|
+
}
|
|
1657
|
+
catch (e) {
|
|
1658
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1659
|
+
return errLine(`Could not read file "${sources.file}": ${msg}`, opts.json);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
else {
|
|
1663
|
+
text = sources.stdin;
|
|
1664
|
+
}
|
|
1665
|
+
const pf = await preflightPark(runId, "taste", "workflow gate edit", opts.json, deps);
|
|
1666
|
+
if (!pf.ok)
|
|
1667
|
+
return pf.result;
|
|
1668
|
+
const cands = gateCandidates(pf.run);
|
|
1669
|
+
const cand = cands.find((c) => c.n === n);
|
|
1670
|
+
if (!cand)
|
|
1671
|
+
return rangeError(n, cands.length, opts.json);
|
|
1672
|
+
const res = await deps.post(GATE_EDIT_PATH, {
|
|
1673
|
+
runId,
|
|
1674
|
+
nodeId: pf.run.pausedNodeId,
|
|
1675
|
+
outputIndex: cand.outputIndex,
|
|
1676
|
+
text,
|
|
1677
|
+
});
|
|
1678
|
+
if (!res.ok)
|
|
1679
|
+
return triggerErrorResult(res, "workflow gate edit", opts.json);
|
|
1680
|
+
return okLine(`Edited candidate ${n} at ${pf.run.pausedNodeId}.`, { ok: true, runId, nodeId: pf.run.pausedNodeId, outputIndex: cand.outputIndex }, opts.json);
|
|
1681
|
+
}
|
|
1682
|
+
function resolveGateSessionId(run) {
|
|
1683
|
+
const gateNode = (run.nodes ?? []).find((x) => x.nodeId === run.pausedNodeId);
|
|
1684
|
+
const artifact = (gateNode?.outputs ?? []).find((a) => a.type === "session" && a.port === "session" && !!a.sessionId);
|
|
1685
|
+
if (artifact)
|
|
1686
|
+
return artifact.sessionId;
|
|
1687
|
+
const sessions = run.sessions ?? [];
|
|
1688
|
+
const byNode = sessions.find((s) => s.nodeId === run.pausedNodeId);
|
|
1689
|
+
if (byNode)
|
|
1690
|
+
return byNode.sessionId;
|
|
1691
|
+
return undefined;
|
|
1692
|
+
}
|
|
1693
|
+
export async function gatePushFlow(runId, message, opts, deps) {
|
|
1694
|
+
const pf = await preflightPark(runId, "taste", "workflow gate push", opts.json, deps);
|
|
1695
|
+
if (!pf.ok)
|
|
1696
|
+
return pf.result;
|
|
1697
|
+
const sessionId = resolveGateSessionId(pf.run);
|
|
1698
|
+
if (!sessionId)
|
|
1699
|
+
return errLine("This gate has no live session — nothing to push to.", opts.json);
|
|
1700
|
+
if (!deps.postDashboard) {
|
|
1701
|
+
return errLine("gate push is unavailable here (no dashboard client).", opts.json);
|
|
1702
|
+
}
|
|
1703
|
+
const chat = await deps.postDashboard(CHAT_PATH, { sessionId, text: message }, { timeoutMs: CHAT_TIMEOUT_MS });
|
|
1704
|
+
if (!chat.ok) {
|
|
1705
|
+
const err = routeErrorText(chat.data, chat.status);
|
|
1706
|
+
return {
|
|
1707
|
+
code: 1,
|
|
1708
|
+
lines: opts.json
|
|
1709
|
+
? [JSON.stringify({ ok: false, status: chat.status, error: err })]
|
|
1710
|
+
: [`exodus workflow gate push: ${err} (HTTP ${chat.status})`],
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
const res = await deps.post(GATE_APPEND_PATH, { runId, nodeId: pf.run.pausedNodeId });
|
|
1714
|
+
if (!res.ok)
|
|
1715
|
+
return triggerErrorResult(res, "workflow gate push", opts.json);
|
|
1716
|
+
const after = await preflightPark(runId, "taste", "workflow gate push", opts.json, deps);
|
|
1717
|
+
const cands = after.ok ? gateCandidates(after.run) : [];
|
|
1718
|
+
const newest = cands[cands.length - 1];
|
|
1719
|
+
if (opts.json) {
|
|
1720
|
+
return {
|
|
1721
|
+
code: 0,
|
|
1722
|
+
lines: [
|
|
1723
|
+
JSON.stringify({
|
|
1724
|
+
ok: true,
|
|
1725
|
+
runId,
|
|
1726
|
+
nodeId: pf.run.pausedNodeId,
|
|
1727
|
+
candidate: newest ? { n: newest.n, text: newest.text } : null,
|
|
1728
|
+
}),
|
|
1729
|
+
],
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
const lines = [`Pushed to the gate — banked a new candidate at ${pf.run.pausedNodeId}.`];
|
|
1733
|
+
if (newest) {
|
|
1734
|
+
lines.push("", `New candidate:`, ` ${newest.n}. ${truncateText(newest.text, 200)}`);
|
|
1735
|
+
}
|
|
1736
|
+
return { code: 0, lines };
|
|
1737
|
+
}
|
|
1738
|
+
export async function gateApproveFlow(runId, opts, deps) {
|
|
1739
|
+
const pf = await preflightPark(runId, "taste", "workflow gate approve", opts.json, deps);
|
|
1740
|
+
if (!pf.ok)
|
|
1741
|
+
return pf.result;
|
|
1742
|
+
const res = await deps.post(APPROVE_PATH, { runId });
|
|
1743
|
+
if (!res.ok)
|
|
1744
|
+
return triggerErrorResult(res, "workflow gate approve", opts.json);
|
|
1745
|
+
const triggerRunId = res.data.triggerRunId;
|
|
1746
|
+
return resumeAndMaybeWait(runId, triggerRunId, ["Gate approved — the run resumes."], opts, deps);
|
|
1747
|
+
}
|
|
1748
|
+
export async function gateRejectFlow(runId, opts, deps) {
|
|
1749
|
+
const pf = await preflightPark(runId, "taste", "workflow gate reject", opts.json, deps);
|
|
1750
|
+
if (!pf.ok)
|
|
1751
|
+
return pf.result;
|
|
1752
|
+
const res = await deps.post(CANCEL_PATH, {
|
|
1753
|
+
runId,
|
|
1754
|
+
...(opts.reason !== undefined ? { reason: opts.reason } : {}),
|
|
1755
|
+
});
|
|
1756
|
+
if (!res.ok)
|
|
1757
|
+
return triggerErrorResult(res, "workflow gate reject", opts.json);
|
|
1758
|
+
return okLine(`Gate rejected — run ${runId} canceled.`, { ok: true, runId }, opts.json);
|
|
1759
|
+
}
|
|
1760
|
+
export async function repairFlow(runId, action, opts, deps) {
|
|
1761
|
+
const verb = `workflow repair ${action}`;
|
|
1762
|
+
const pf = await preflightPark(runId, "repair", verb, opts.json, deps);
|
|
1763
|
+
if (!pf.ok)
|
|
1764
|
+
return pf.result;
|
|
1765
|
+
if (action === "kill") {
|
|
1766
|
+
const res = await deps.post(CANCEL_PATH, { runId });
|
|
1767
|
+
if (!res.ok)
|
|
1768
|
+
return triggerErrorResult(res, verb, opts.json);
|
|
1769
|
+
return okLine(`Repair killed — run ${runId} canceled.`, { ok: true, runId }, opts.json);
|
|
1770
|
+
}
|
|
1771
|
+
const path = action === "retry" ? REPAIR_RETRY_PATH : REPAIR_SKIP_PATH;
|
|
1772
|
+
const res = await deps.post(path, { runId });
|
|
1773
|
+
if (!res.ok)
|
|
1774
|
+
return triggerErrorResult(res, verb, opts.json);
|
|
1775
|
+
const triggerRunId = res.data.triggerRunId;
|
|
1776
|
+
return resumeAndMaybeWait(runId, triggerRunId, [`Repair ${action} started — the run resumes.`], opts, deps);
|
|
1777
|
+
}
|
|
1778
|
+
async function resumeAndMaybeWait(runId, triggerRunId, headline, opts, deps) {
|
|
1779
|
+
const base = { runId, triggerRunId };
|
|
1780
|
+
const startLines = [
|
|
1781
|
+
...headline,
|
|
1782
|
+
`runId: ${runId}`,
|
|
1783
|
+
`triggerRunId: ${triggerRunId ?? "-"}`,
|
|
1784
|
+
`Poll: exodus workflow status --id ${runId}`,
|
|
1785
|
+
];
|
|
1786
|
+
if (!opts.wait) {
|
|
1787
|
+
if (opts.json)
|
|
1788
|
+
return { code: 0, lines: [JSON.stringify(base)] };
|
|
1789
|
+
return { code: 0, lines: startLines };
|
|
1790
|
+
}
|
|
1791
|
+
if (!opts.json && opts.onProgressLine) {
|
|
1792
|
+
for (const line of startLines)
|
|
1793
|
+
opts.onProgressLine(line);
|
|
1794
|
+
}
|
|
1795
|
+
const waited = await waitForRun(runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base }, deps);
|
|
1796
|
+
if (opts.json)
|
|
1797
|
+
return waited;
|
|
1798
|
+
const prefix = opts.onProgressLine ? [] : startLines;
|
|
1799
|
+
return { code: waited.code, lines: [...prefix, ...waited.lines] };
|
|
1800
|
+
}
|
|
1801
|
+
export function parseSlotFlags(args) {
|
|
1802
|
+
const values = {};
|
|
1803
|
+
for (let i = 0; i < args.length; i++) {
|
|
1804
|
+
const arg = args[i];
|
|
1805
|
+
let raw;
|
|
1806
|
+
if (arg === "--slot") {
|
|
1807
|
+
raw = args[i + 1];
|
|
1808
|
+
i++;
|
|
1809
|
+
}
|
|
1810
|
+
else if (arg.startsWith("--slot=")) {
|
|
1811
|
+
raw = arg.slice("--slot=".length);
|
|
1812
|
+
}
|
|
1813
|
+
else {
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
if (!raw)
|
|
1817
|
+
throw new Error("--slot requires key=value");
|
|
1818
|
+
const eq = raw.indexOf("=");
|
|
1819
|
+
if (eq <= 0)
|
|
1820
|
+
throw new Error(`--slot must be key=value (got "${raw}")`);
|
|
1821
|
+
const key = raw.slice(0, eq).trim();
|
|
1822
|
+
if (!key)
|
|
1823
|
+
throw new Error(`--slot must include a key (got "${raw}")`);
|
|
1824
|
+
values[key] = raw.slice(eq + 1);
|
|
1825
|
+
}
|
|
1826
|
+
return values;
|
|
1827
|
+
}
|
|
1828
|
+
export async function answerFlow(runId, values, opts, deps) {
|
|
1829
|
+
const pf = await preflightPark(runId, "slots", "workflow answer", opts.json, deps);
|
|
1830
|
+
if (!pf.ok)
|
|
1831
|
+
return pf.result;
|
|
1832
|
+
const pending = pf.run.pendingSlots ?? [];
|
|
1833
|
+
if (Object.keys(values).length === 0) {
|
|
1834
|
+
if (opts.json) {
|
|
1835
|
+
return { code: 0, lines: [JSON.stringify({ runId, pendingSlots: pending })] };
|
|
1836
|
+
}
|
|
1837
|
+
const lines = [`Run ${runId} is waiting on ${pending.length} slot answer(s):`];
|
|
1838
|
+
for (const s of pending) {
|
|
1839
|
+
const label = s.label ? ` — ${s.label}` : "";
|
|
1840
|
+
const hint = s.hint ? ` (${s.hint})` : "";
|
|
1841
|
+
lines.push(` ${s.id}${label}${hint}`);
|
|
1842
|
+
}
|
|
1843
|
+
lines.push("", `Answer: exodus workflow answer ${runId} --slot ${pending[0]?.id ?? "key"}=value`);
|
|
1844
|
+
return { code: 0, lines };
|
|
1845
|
+
}
|
|
1846
|
+
const res = await deps.post(ANSWER_PATH, { runId, values });
|
|
1847
|
+
if (!res.ok)
|
|
1848
|
+
return triggerErrorResult(res, "workflow answer", opts.json);
|
|
1849
|
+
return okLine(`Answered ${Object.keys(values).length} slot(s) for run ${runId} — the child resumes.`, { ok: true, runId, values }, opts.json);
|
|
1850
|
+
}
|
|
667
1851
|
function flagString(flags, name) {
|
|
668
1852
|
const v = flags[name];
|
|
669
1853
|
return typeof v === "string" ? v : undefined;
|
|
@@ -692,6 +1876,37 @@ async function printResult(result) {
|
|
|
692
1876
|
if (result.code !== 0)
|
|
693
1877
|
process.exit(result.code);
|
|
694
1878
|
}
|
|
1879
|
+
function parsePickNumbers(raw) {
|
|
1880
|
+
if (raw === undefined)
|
|
1881
|
+
return undefined;
|
|
1882
|
+
const parts = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1883
|
+
if (parts.length === 0)
|
|
1884
|
+
return undefined;
|
|
1885
|
+
const nums = [];
|
|
1886
|
+
for (const p of parts) {
|
|
1887
|
+
if (!/^\d+$/.test(p))
|
|
1888
|
+
return undefined;
|
|
1889
|
+
nums.push(Number(p));
|
|
1890
|
+
}
|
|
1891
|
+
return nums;
|
|
1892
|
+
}
|
|
1893
|
+
async function readAllStdin() {
|
|
1894
|
+
const chunks = [];
|
|
1895
|
+
for await (const chunk of process.stdin)
|
|
1896
|
+
chunks.push(chunk);
|
|
1897
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
1898
|
+
}
|
|
1899
|
+
async function maybeReadStdin(flags) {
|
|
1900
|
+
if (flagString(flags, "text") !== undefined || flagString(flags, "file") !== undefined) {
|
|
1901
|
+
return undefined;
|
|
1902
|
+
}
|
|
1903
|
+
if (process.stdin.isTTY)
|
|
1904
|
+
return undefined;
|
|
1905
|
+
const raw = await readAllStdin();
|
|
1906
|
+
if (raw.length === 0)
|
|
1907
|
+
return undefined;
|
|
1908
|
+
return raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
|
1909
|
+
}
|
|
695
1910
|
export async function run(flags) {
|
|
696
1911
|
const positional = parsePositional();
|
|
697
1912
|
const [sub, ...rest] = positional;
|
|
@@ -714,6 +1929,36 @@ export async function run(flags) {
|
|
|
714
1929
|
if (sub === "bots") {
|
|
715
1930
|
return printResult(await botsFlow({ category: flagString(flags, "category"), slug: flagString(flags, "slug"), json }, defaultDeps));
|
|
716
1931
|
}
|
|
1932
|
+
if (sub === "templates") {
|
|
1933
|
+
const action = rest[0];
|
|
1934
|
+
if (!action || action === "list") {
|
|
1935
|
+
return printResult(await templatesListFlow(json, defaultDeps));
|
|
1936
|
+
}
|
|
1937
|
+
if (action === "export") {
|
|
1938
|
+
const key = rest[1];
|
|
1939
|
+
if (!key) {
|
|
1940
|
+
console.error("Error: workflow templates export requires <key>.");
|
|
1941
|
+
console.log("Usage: exodus workflow templates export <key> [--out <file>] [--json]");
|
|
1942
|
+
process.exit(1);
|
|
1943
|
+
}
|
|
1944
|
+
return printResult(await templatesExportFlow(key, { out: flagString(flags, "out"), json }, defaultDeps));
|
|
1945
|
+
}
|
|
1946
|
+
console.error(`Unknown templates action: "${action}"`);
|
|
1947
|
+
console.log("Usage: exodus workflow templates [list] | exodus workflow templates export <key>");
|
|
1948
|
+
process.exit(1);
|
|
1949
|
+
}
|
|
1950
|
+
if (sub === "schema") {
|
|
1951
|
+
return printResult(await schemaFlow({ json, kind: flagString(flags, "kind"), face: flagString(flags, "face") }, defaultDeps));
|
|
1952
|
+
}
|
|
1953
|
+
if (sub === "validate") {
|
|
1954
|
+
const file = rest[0];
|
|
1955
|
+
if (!file) {
|
|
1956
|
+
console.error("Error: workflow validate requires <file>.");
|
|
1957
|
+
console.log("Usage: exodus workflow validate <file> [--update <workflowId>] [--json]");
|
|
1958
|
+
process.exit(1);
|
|
1959
|
+
}
|
|
1960
|
+
return printResult(await validateFlow(file, { json, update: flagString(flags, "update") }, defaultDeps));
|
|
1961
|
+
}
|
|
717
1962
|
if (sub === "run") {
|
|
718
1963
|
const workflowRef = rest[0];
|
|
719
1964
|
if (!workflowRef) {
|
|
@@ -722,8 +1967,10 @@ export async function run(flags) {
|
|
|
722
1967
|
process.exit(1);
|
|
723
1968
|
}
|
|
724
1969
|
let inputs;
|
|
1970
|
+
let terminalNodeIds;
|
|
725
1971
|
try {
|
|
726
1972
|
inputs = parseInputFlags(process.argv.slice(3), defaultDeps.readFile);
|
|
1973
|
+
terminalNodeIds = parseTerminalFlags(process.argv.slice(3));
|
|
727
1974
|
}
|
|
728
1975
|
catch (e) {
|
|
729
1976
|
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -731,6 +1978,7 @@ export async function run(flags) {
|
|
|
731
1978
|
}
|
|
732
1979
|
return printResult(await runFlow(workflowRef, {
|
|
733
1980
|
inputs,
|
|
1981
|
+
terminalNodeIds,
|
|
734
1982
|
wait: flags["wait"] === true,
|
|
735
1983
|
json,
|
|
736
1984
|
onProgressLine: (line) => console.log(line),
|
|
@@ -739,14 +1987,31 @@ export async function run(flags) {
|
|
|
739
1987
|
if (sub === "status") {
|
|
740
1988
|
return printResult(await statusFlow({ id: flagString(flags, "id"), json }, defaultDeps));
|
|
741
1989
|
}
|
|
1990
|
+
if (sub === "versions") {
|
|
1991
|
+
const workflowRef = rest[0];
|
|
1992
|
+
if (!workflowRef) {
|
|
1993
|
+
console.error("Error: workflow versions requires <workflowId|name>.");
|
|
1994
|
+
console.log("Usage: exodus workflow versions <workflowId|name> [--json]");
|
|
1995
|
+
process.exit(1);
|
|
1996
|
+
}
|
|
1997
|
+
return printResult(await versionsFlow(workflowRef, { json }, defaultDeps));
|
|
1998
|
+
}
|
|
742
1999
|
if (sub === "export") {
|
|
743
2000
|
const workflowRef = rest[0];
|
|
744
2001
|
if (!workflowRef) {
|
|
745
2002
|
console.error("Error: workflow export requires <workflowId|name>.");
|
|
746
|
-
console.log("Usage: exodus workflow export <workflowId|name> [--out <file>]");
|
|
2003
|
+
console.log("Usage: exodus workflow export <workflowId|name> [--out <file>] [--version <n>]");
|
|
2004
|
+
process.exit(1);
|
|
2005
|
+
}
|
|
2006
|
+
let version;
|
|
2007
|
+
try {
|
|
2008
|
+
version = parseVersionFlag(flags);
|
|
2009
|
+
}
|
|
2010
|
+
catch (e) {
|
|
2011
|
+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
747
2012
|
process.exit(1);
|
|
748
2013
|
}
|
|
749
|
-
return printResult(await exportFlow(workflowRef, { out: flagString(flags, "out") }, defaultDeps));
|
|
2014
|
+
return printResult(await exportFlow(workflowRef, { out: flagString(flags, "out"), json, version }, defaultDeps));
|
|
750
2015
|
}
|
|
751
2016
|
if (sub === "import") {
|
|
752
2017
|
const file = rest[0];
|
|
@@ -757,7 +2022,135 @@ export async function run(flags) {
|
|
|
757
2022
|
}
|
|
758
2023
|
return printResult(await importFlow(file, { dryRun: flags["dry-run"] === true, json, update: flagString(flags, "update") }, defaultDeps));
|
|
759
2024
|
}
|
|
2025
|
+
if (sub === "inbox") {
|
|
2026
|
+
return printResult(await inboxFlow(json, defaultDeps));
|
|
2027
|
+
}
|
|
2028
|
+
if (sub === "gate") {
|
|
2029
|
+
const runId = rest[0];
|
|
2030
|
+
if (!runId) {
|
|
2031
|
+
console.error("Error: workflow gate requires <runId>.");
|
|
2032
|
+
console.log("Usage: exodus workflow gate <runId> [pick <n,..> | edit <n> | push \"msg\" | approve | reject]");
|
|
2033
|
+
process.exit(1);
|
|
2034
|
+
}
|
|
2035
|
+
const action = rest[1];
|
|
2036
|
+
if (!action)
|
|
2037
|
+
return printResult(await gateShowFlow(runId, { json }, defaultDeps));
|
|
2038
|
+
if (action === "pick") {
|
|
2039
|
+
const numbers = parsePickNumbers(rest[2]);
|
|
2040
|
+
if (!numbers) {
|
|
2041
|
+
console.error(`Error: gate pick needs comma-separated 1-based numbers (e.g. \`pick 3,7\`).`);
|
|
2042
|
+
process.exit(1);
|
|
2043
|
+
}
|
|
2044
|
+
return printResult(await gatePickFlow(runId, numbers, { json }, defaultDeps));
|
|
2045
|
+
}
|
|
2046
|
+
if (action === "edit") {
|
|
2047
|
+
const n = parseTriggerIndex(rest[2]);
|
|
2048
|
+
if (n === undefined) {
|
|
2049
|
+
console.error("Error: gate edit needs a 1-based candidate number <n>.");
|
|
2050
|
+
console.log(`Usage: exodus workflow gate <runId> edit <n> [--text "..." | --file <path> | (stdin)]`);
|
|
2051
|
+
process.exit(1);
|
|
2052
|
+
}
|
|
2053
|
+
const stdin = await maybeReadStdin(flags);
|
|
2054
|
+
return printResult(await gateEditFlow(runId, n, { text: flagString(flags, "text"), file: flagString(flags, "file"), stdin }, { json }, defaultDeps));
|
|
2055
|
+
}
|
|
2056
|
+
if (action === "push") {
|
|
2057
|
+
const message = rest[2];
|
|
2058
|
+
if (!message) {
|
|
2059
|
+
console.error('Error: gate push needs a message (e.g. `push "make it punchier"`).');
|
|
2060
|
+
process.exit(1);
|
|
2061
|
+
}
|
|
2062
|
+
return printResult(await gatePushFlow(runId, message, { json }, defaultDeps));
|
|
2063
|
+
}
|
|
2064
|
+
if (action === "approve") {
|
|
2065
|
+
return printResult(await gateApproveFlow(runId, { wait: flags["wait"] === true, json, onProgressLine: (line) => console.log(line) }, defaultDeps));
|
|
2066
|
+
}
|
|
2067
|
+
if (action === "reject") {
|
|
2068
|
+
return printResult(await gateRejectFlow(runId, { reason: flagString(flags, "reason"), json }, defaultDeps));
|
|
2069
|
+
}
|
|
2070
|
+
console.error(`Error: unknown gate action "${action}" (expected pick, edit, push, approve, or reject).`);
|
|
2071
|
+
process.exit(1);
|
|
2072
|
+
}
|
|
2073
|
+
if (sub === "repair") {
|
|
2074
|
+
const runId = rest[0];
|
|
2075
|
+
if (!runId) {
|
|
2076
|
+
console.error("Error: workflow repair requires <runId>.");
|
|
2077
|
+
console.log("Usage: exodus workflow repair <runId> retry|skip|kill [--wait]");
|
|
2078
|
+
process.exit(1);
|
|
2079
|
+
}
|
|
2080
|
+
const action = rest[1];
|
|
2081
|
+
if (action !== "retry" && action !== "skip" && action !== "kill") {
|
|
2082
|
+
console.error(`Error: workflow repair needs an action (retry, skip, or kill)${action ? ` — got "${action}"` : ""}.`);
|
|
2083
|
+
console.log("Usage: exodus workflow repair <runId> retry|skip|kill [--wait]");
|
|
2084
|
+
process.exit(1);
|
|
2085
|
+
}
|
|
2086
|
+
return printResult(await repairFlow(runId, action, { wait: flags["wait"] === true, json, onProgressLine: (line) => console.log(line) }, defaultDeps));
|
|
2087
|
+
}
|
|
2088
|
+
if (sub === "answer") {
|
|
2089
|
+
const runId = rest[0];
|
|
2090
|
+
if (!runId) {
|
|
2091
|
+
console.error("Error: workflow answer requires <runId>.");
|
|
2092
|
+
console.log("Usage: exodus workflow answer <runId> --slot key=value [--slot key=value ...]");
|
|
2093
|
+
process.exit(1);
|
|
2094
|
+
}
|
|
2095
|
+
let values;
|
|
2096
|
+
try {
|
|
2097
|
+
values = parseSlotFlags(process.argv.slice(3));
|
|
2098
|
+
}
|
|
2099
|
+
catch (e) {
|
|
2100
|
+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
2101
|
+
process.exit(1);
|
|
2102
|
+
}
|
|
2103
|
+
return printResult(await answerFlow(runId, values, { json }, defaultDeps));
|
|
2104
|
+
}
|
|
2105
|
+
if (sub === "triggers") {
|
|
2106
|
+
const workflowRef = rest[0];
|
|
2107
|
+
if (!workflowRef) {
|
|
2108
|
+
console.error("Error: workflow triggers requires <workflowId|name>.");
|
|
2109
|
+
console.log("Usage: exodus workflow triggers <workflowId|name> [enable|disable <n> | fire [<n>]] [--json]");
|
|
2110
|
+
process.exit(1);
|
|
2111
|
+
}
|
|
2112
|
+
const action = rest[1];
|
|
2113
|
+
if (!action)
|
|
2114
|
+
return printResult(await triggersListFlow(workflowRef, { json }, defaultDeps));
|
|
2115
|
+
if (action === "enable" || action === "disable") {
|
|
2116
|
+
const n = parseTriggerIndex(rest[2]);
|
|
2117
|
+
if (n === undefined) {
|
|
2118
|
+
console.error(`Error: workflow triggers ${action} requires a 1-based trigger number <n>.`);
|
|
2119
|
+
console.log(`Usage: exodus workflow triggers <workflowId|name> ${action} <n>`);
|
|
2120
|
+
process.exit(1);
|
|
2121
|
+
}
|
|
2122
|
+
return printResult(await triggersSetEnabledFlow(workflowRef, n, action === "enable", { json }, defaultDeps));
|
|
2123
|
+
}
|
|
2124
|
+
if (action === "fire") {
|
|
2125
|
+
let n;
|
|
2126
|
+
if (rest[2] !== undefined) {
|
|
2127
|
+
n = parseTriggerIndex(rest[2]);
|
|
2128
|
+
if (n === undefined) {
|
|
2129
|
+
console.error(`Error: trigger number must be a positive integer (got "${rest[2]}").`);
|
|
2130
|
+
process.exit(1);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
return printResult(await triggersFireFlow(workflowRef, {
|
|
2134
|
+
n,
|
|
2135
|
+
text: flagString(flags, "text"),
|
|
2136
|
+
wait: flags["wait"] === true,
|
|
2137
|
+
json,
|
|
2138
|
+
onProgressLine: (line) => console.log(line),
|
|
2139
|
+
}, defaultDeps));
|
|
2140
|
+
}
|
|
2141
|
+
console.error(`Error: unknown triggers action "${action}" (expected enable, disable, or fire).`);
|
|
2142
|
+
console.log("Usage: exodus workflow triggers <workflowId|name> [enable|disable <n> | fire [<n>]] [--json]");
|
|
2143
|
+
process.exit(1);
|
|
2144
|
+
}
|
|
760
2145
|
console.error(`Unknown subcommand: "${sub}"\n`);
|
|
761
2146
|
console.log(helpText);
|
|
762
2147
|
process.exit(1);
|
|
763
2148
|
}
|
|
2149
|
+
function parseTriggerIndex(raw) {
|
|
2150
|
+
if (raw === undefined)
|
|
2151
|
+
return undefined;
|
|
2152
|
+
if (!/^\d+$/.test(raw))
|
|
2153
|
+
return undefined;
|
|
2154
|
+
const n = Number(raw);
|
|
2155
|
+
return n >= 1 ? n : undefined;
|
|
2156
|
+
}
|