@aicopycoders/exodus 2026.7.200-beta.11 → 2026.7.200-beta.12

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.
@@ -1,7 +1,10 @@
1
1
  import fs from "node:fs";
2
- import { apiGet, apiPost } from "../lib/client.js";
2
+ import { apiGet, apiGetText, apiPost, apiPostDashboard, getDashboardUrl, } from "../lib/client.js";
3
3
  import { formatError } 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 run <workflowId|name> [--input key=value ...] [--wait] [--json]
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 export <workflowId|name> [--out <file>]
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 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
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 export "Launch Flow" --out workflow.json
43
- exodus workflow import workflow.json --dry-run
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,13 +158,49 @@ 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 VALUE_FLAGS = new Set(["id", "input", "out", "category", "slug", "update"]);
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 {
@@ -119,6 +258,27 @@ function progressLine(node) {
119
258
  const err = node.error ? ` — error: ${node.error}` : "";
120
259
  return ` ${statusIcon(node.status)} ${node.nodeId} (${node.kind}) ${node.status}${err}`;
121
260
  }
261
+ export function formatPauseNotice(pauseReason, runId, dashboardUrl) {
262
+ if (!pauseReason) {
263
+ return [
264
+ " ⏸ paused at the cost gate — approve or edit the storyboard in the web app to continue.",
265
+ ];
266
+ }
267
+ if (pauseReason === "call") {
268
+ return [" ⏸ waiting on a child workflow run — it resumes on its own."];
269
+ }
270
+ const resolveVerb = pauseReason === "repair"
271
+ ? `exodus workflow repair ${runId} retry|skip|kill`
272
+ : pauseReason === "slots"
273
+ ? `exodus workflow answer ${runId} --slot key=value`
274
+ :
275
+ `exodus workflow gate ${runId}`;
276
+ return [
277
+ " ⏸ paused for review — waiting on you.",
278
+ ` Resolve here: ${resolveVerb}`,
279
+ ` Or in the app: ${dashboardUrl}${RUN_PAGE_PREFIX}${runId}`,
280
+ ];
281
+ }
122
282
  function expandInputValue(key, value, readFile) {
123
283
  if (value.startsWith("@@"))
124
284
  return value.slice(1);
@@ -167,6 +327,30 @@ export function parseInputFlags(args, readFile) {
167
327
  }
168
328
  return inputs;
169
329
  }
330
+ export function parseTerminalFlags(args) {
331
+ const ids = [];
332
+ for (let i = 0; i < args.length; i++) {
333
+ const arg = args[i];
334
+ let raw;
335
+ if (arg === "--terminal") {
336
+ raw = args[i + 1];
337
+ i++;
338
+ }
339
+ else if (arg.startsWith("--terminal=")) {
340
+ raw = arg.slice("--terminal=".length);
341
+ }
342
+ else {
343
+ continue;
344
+ }
345
+ if (raw === undefined)
346
+ throw new Error("--terminal requires a node id");
347
+ const id = raw.trim();
348
+ if (!id)
349
+ throw new Error("--terminal requires a node id");
350
+ ids.push(id);
351
+ }
352
+ return ids;
353
+ }
170
354
  export function formatWorkflowList(workflows) {
171
355
  if (workflows.length === 0)
172
356
  return "No workflows found for the active brand.";
@@ -183,15 +367,31 @@ export function formatRecentRuns(runs) {
183
367
  return "No workflow runs found for the active brand.";
184
368
  return table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, r.status, dateOnly(r.createdAt), r._id]));
185
369
  }
370
+ export function formatWorkflowVersions(versions) {
371
+ if (versions.length === 0) {
372
+ return "no saved versions yet — versions start recording on the workflow's next save";
373
+ }
374
+ const lines = versions.map((v) => {
375
+ const by = v.savedByName ? ` · by ${v.savedByName}` : "";
376
+ return `v${v.version} · ${v.name} · saved ${dateOnly(v.savedAt)}${by}`;
377
+ });
378
+ if (versions.length === VERSIONS_CAP) {
379
+ lines.push("");
380
+ lines.push(`(showing the ${VERSIONS_CAP} most recent versions — older versions may exist beyond this cap)`);
381
+ }
382
+ return lines.join("\n");
383
+ }
186
384
  export function formatImportSummary(result, mode = {}) {
187
385
  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:";
386
+ const heading = mode.validate
387
+ ? "Workflow validation passed:"
388
+ : mode.dryRun
389
+ ? "Workflow import preview:"
390
+ : result.created
391
+ ? "Workflow imported."
392
+ : mode.update
393
+ ? "Workflow updated."
394
+ : "Workflow import preview:";
195
395
  lines.push(heading);
196
396
  lines.push(`name: ${result.name}`);
197
397
  if (result.workflowId)
@@ -245,6 +445,13 @@ export function formatWorkflowRun(run) {
245
445
  for (const output of run.outputs)
246
446
  lines.push(...runOutputLines(output));
247
447
  }
448
+ if (run.sessions && run.sessions.length > 0) {
449
+ lines.push("");
450
+ lines.push(`Sessions (${run.sessions.length}):`);
451
+ for (const s of run.sessions) {
452
+ lines.push(` session: ${s.sessionId} · "${s.title}" · continue: exodus session chat ${s.sessionId} "..."`);
453
+ }
454
+ }
248
455
  return lines.join("\n");
249
456
  }
250
457
  function runOutputLines(output) {
@@ -335,6 +542,9 @@ function formatBotVocabulary(catalog) {
335
542
  lines.push(` aspect ratios: ${v.aspectRatios.join(", ")}`);
336
543
  lines.push(` custom bot: set a bot node's slug to "${catalog.customBot.slug}" and ` +
337
544
  `config.${catalog.customBot.configKey}=<genesis-slug> to reach any bot not listed here.`);
545
+ lines.push(` prompt bot: set a bot node's slug to "${catalog.promptBot.slug}" and ` +
546
+ `config.${catalog.promptBot.configKey}=<your instructions> — the prompt IS the bot ` +
547
+ "(runs on the workspace's own LLM key, not Genesis).");
338
548
  return lines.join("\n");
339
549
  }
340
550
  export function formatBotsList(catalog, category) {
@@ -370,7 +580,6 @@ export function formatBotDetail(bot) {
370
580
  lines.push(`category: ${bot.categoryLabel} (${bot.category})`);
371
581
  lines.push(`blurb: ${bot.blurb}`);
372
582
  lines.push(`outputType: ${bot.outputType}`);
373
- lines.push(`splits: ${bot.splitsOutput ? "yes (can split into per-item deliverables)" : "no"}`);
374
583
  lines.push("");
375
584
  lines.push(`Input ports (${bot.inputs.length}):`);
376
585
  if (bot.inputs.length === 0) {
@@ -412,6 +621,161 @@ export function formatBotDetail(bot) {
412
621
  }
413
622
  return lines.join("\n");
414
623
  }
624
+ function firstLine(text) {
625
+ const nl = text.indexOf("\n");
626
+ return (nl === -1 ? text : text.slice(0, nl)).trim();
627
+ }
628
+ export function formatTemplatesList(templates) {
629
+ if (templates.length === 0)
630
+ return "No workflow templates available on this backend.";
631
+ const lines = [
632
+ table(["key", "label", "description"], templates.map((t) => [
633
+ t.key,
634
+ t.module ? `${t.label} [${t.module} module]` : t.label,
635
+ firstLine(t.description ?? ""),
636
+ ])),
637
+ ];
638
+ if (templates.some((t) => t.module)) {
639
+ lines.push("");
640
+ lines.push("Templates badged [<module> module] are owned by a module (e.g. video) — " +
641
+ "their runs start from the show surfaces, not `workflow run`. Export one to " +
642
+ "study or adapt its graph.");
643
+ }
644
+ lines.push("");
645
+ lines.push("Export one to start authoring: exodus workflow templates export <key> --out my.yaml");
646
+ return lines.join("\n");
647
+ }
648
+ const SCHEMA_LABEL_KEYS = ["kind", "face", "type", "name", "id", "code", "key", "label"];
649
+ function schemaEntryLabel(entry) {
650
+ for (const key of SCHEMA_LABEL_KEYS) {
651
+ const v = entry[key];
652
+ if (typeof v === "string" && v)
653
+ return { key, value: v };
654
+ }
655
+ return undefined;
656
+ }
657
+ function schemaValueLines(value, indent) {
658
+ if (value === null || value === undefined)
659
+ return [`${indent}(none)`];
660
+ if (Array.isArray(value)) {
661
+ if (value.length === 0)
662
+ return [`${indent}(none)`];
663
+ const lines = [];
664
+ for (const el of value) {
665
+ if (isRecord(el)) {
666
+ const label = schemaEntryLabel(el);
667
+ if (label) {
668
+ lines.push(`${indent}- ${label.value}`);
669
+ const rest = { ...el };
670
+ delete rest[label.key];
671
+ lines.push(...schemaValueLines(rest, `${indent} `));
672
+ }
673
+ else {
674
+ lines.push(`${indent}-`);
675
+ lines.push(...schemaValueLines(el, `${indent} `));
676
+ }
677
+ }
678
+ else {
679
+ lines.push(`${indent}- ${String(el)}`);
680
+ }
681
+ }
682
+ return lines;
683
+ }
684
+ if (isRecord(value)) {
685
+ const entries = Object.entries(value);
686
+ if (entries.length === 0)
687
+ return [`${indent}(none)`];
688
+ const lines = [];
689
+ for (const [k, v] of entries) {
690
+ if (isRecord(v) || Array.isArray(v)) {
691
+ lines.push(`${indent}${k}:`);
692
+ lines.push(...schemaValueLines(v, `${indent} `));
693
+ }
694
+ else {
695
+ lines.push(`${indent}${k}: ${String(v)}`);
696
+ }
697
+ }
698
+ return lines;
699
+ }
700
+ return [`${indent}${String(value)}`];
701
+ }
702
+ const SCHEMA_SECTIONS = [
703
+ { title: "Graph contract version", keys: ["graphVersion"] },
704
+ { title: "Port types", keys: ["portTypes"] },
705
+ { title: "Node kinds", keys: ["nodeKinds"] },
706
+ { title: "Transform faces", keys: ["transformFaces"] },
707
+ { title: "Gate policies", keys: ["gatePolicies"] },
708
+ { title: "Collector policy", keys: ["collectorPolicy"] },
709
+ { title: "Deposits", keys: ["deposits"] },
710
+ { title: "Slots", keys: ["slots"] },
711
+ { title: "Triggers", keys: ["triggers"] },
712
+ { title: "Edge / graph rules", keys: ["edgeRules", "graphRules"] },
713
+ {
714
+ title: "Bots",
715
+ keys: ["botsPointer", "botsPointerNote", "bots-pointer-note", "botsNote", "bots"],
716
+ },
717
+ ];
718
+ function schemaSectionValue(payload, keys) {
719
+ for (const key of keys) {
720
+ if (payload[key] !== undefined)
721
+ return { key, value: payload[key] };
722
+ }
723
+ return undefined;
724
+ }
725
+ export function formatSchema(payload) {
726
+ const lines = [];
727
+ const version = payload["version"];
728
+ lines.push(`Workflow schema${version !== undefined ? ` (version ${version})` : ""}`);
729
+ for (const section of SCHEMA_SECTIONS) {
730
+ const found = schemaSectionValue(payload, section.keys);
731
+ if (!found)
732
+ continue;
733
+ lines.push("");
734
+ lines.push(`${section.title}:`);
735
+ lines.push(...schemaValueLines(found.value, " "));
736
+ }
737
+ return lines.join("\n");
738
+ }
739
+ function schemaEntryIds(value) {
740
+ if (!Array.isArray(value))
741
+ return [];
742
+ const ids = [];
743
+ for (const el of value) {
744
+ if (isRecord(el)) {
745
+ const label = schemaEntryLabel(el);
746
+ if (label)
747
+ ids.push(label.value);
748
+ }
749
+ else if (typeof el === "string") {
750
+ ids.push(el);
751
+ }
752
+ }
753
+ return ids;
754
+ }
755
+ function formatSchemaFilter(payload, sectionKeys, axis, wanted) {
756
+ const found = schemaSectionValue(payload, sectionKeys);
757
+ const list = found && Array.isArray(found.value) ? found.value : [];
758
+ const match = list.find((el) => {
759
+ if (!isRecord(el))
760
+ return typeof el === "string" && el === wanted;
761
+ const label = schemaEntryLabel(el);
762
+ return label?.value === wanted;
763
+ });
764
+ if (match === undefined) {
765
+ const valid = schemaEntryIds(found?.value).join(", ") || "(none)";
766
+ return { code: 1, lines: [`Unknown ${axis} "${wanted}". Valid ${axis}s: ${valid}`] };
767
+ }
768
+ const title = axis === "kind" ? "Node kind" : "Transform face";
769
+ const lines = [`${title}: ${wanted}`];
770
+ if (isRecord(match)) {
771
+ const label = schemaEntryLabel(match);
772
+ const rest = { ...match };
773
+ if (label)
774
+ delete rest[label.key];
775
+ lines.push(...schemaValueLines(rest, " "));
776
+ }
777
+ return { code: 0, lines };
778
+ }
415
779
  function graphIssueLines(issue) {
416
780
  const node = issue.nodeId ? ` [node ${issue.nodeId}]` : "";
417
781
  const port = issue.portId ? ` [port ${issue.portId}]` : "";
@@ -513,6 +877,9 @@ export async function runFlow(workflowRef, opts, deps) {
513
877
  const body = {
514
878
  workflowId,
515
879
  ...(Object.keys(opts.inputs).length > 0 ? { inputs: opts.inputs } : {}),
880
+ ...(opts.terminalNodeIds && opts.terminalNodeIds.length > 0
881
+ ? { terminalNodeIds: opts.terminalNodeIds }
882
+ : {}),
516
883
  };
517
884
  const start = await deps.post(RUN_PATH, body);
518
885
  if (!start.ok)
@@ -536,10 +903,16 @@ export async function runFlow(workflowRef, opts, deps) {
536
903
  opts.onProgressLine(line);
537
904
  lines.length = 0;
538
905
  }
906
+ const waited = await waitForRun(data.runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base }, deps);
907
+ if (opts.json)
908
+ return waited;
909
+ return { code: waited.code, lines: [...lines, ...waited.lines] };
910
+ }
911
+ async function waitForRun(runId, opts, deps) {
539
912
  const seen = new Map();
540
913
  let pausedNotified = false;
541
914
  const pollResult = await deps.poll({
542
- path: `${STATUS_PATH}?runId=${encodeURIComponent(data.runId)}`,
915
+ path: `${STATUS_PATH}?runId=${encodeURIComponent(runId)}`,
543
916
  intervalMs: 3_000,
544
917
  timeoutMs: 60 * 60 * 1000,
545
918
  terminalStatuses: ["completed", "partial", "failed", "canceled"],
@@ -548,7 +921,11 @@ export async function runFlow(workflowRef, opts, deps) {
548
921
  return;
549
922
  if (raw["status"] === "awaiting-review" && !pausedNotified) {
550
923
  pausedNotified = true;
551
- opts.onProgressLine(" ⏸ paused at the cost gate — approve or edit the storyboard in the web app to continue.");
924
+ const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
925
+ const pauseReason = raw["pauseReason"];
926
+ for (const line of formatPauseNotice(pauseReason, runId, dashboardUrl)) {
927
+ opts.onProgressLine(line);
928
+ }
552
929
  }
553
930
  const nodes = Array.isArray(raw["nodes"]) ? raw["nodes"] : [];
554
931
  for (const node of nodes) {
@@ -562,14 +939,18 @@ export async function runFlow(workflowRef, opts, deps) {
562
939
  if (opts.json) {
563
940
  return {
564
941
  code: pollResult.ok ? 0 : 1,
565
- lines: [JSON.stringify({ ...base, result: pollResult.data, timedOut: pollResult.timedOut })],
942
+ lines: [
943
+ JSON.stringify({ ...opts.jsonBase, result: pollResult.data, timedOut: pollResult.timedOut }),
944
+ ],
566
945
  };
567
946
  }
568
947
  if (pollResult.timedOut) {
569
- lines.push(`Timed out waiting. Check later: exodus workflow status --id ${data.runId}`);
570
- return { code: 1, lines };
948
+ return {
949
+ code: 1,
950
+ lines: [`Timed out waiting. Check later: exodus workflow status --id ${runId}`],
951
+ };
571
952
  }
572
- lines.push("");
953
+ const lines = [""];
573
954
  if (isRecord(pollResult.data) && typeof pollResult.data["_id"] === "string") {
574
955
  lines.push(formatWorkflowRun(pollResult.data));
575
956
  }
@@ -598,34 +979,79 @@ export async function exportFlow(workflowRef, opts, deps) {
598
979
  catch (e) {
599
980
  return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
600
981
  }
601
- const res = await deps.get(`${EXPORT_PATH}?id=${encodeURIComponent(workflowId)}`);
982
+ const versionParam = opts.version !== undefined ? `&version=${encodeURIComponent(String(opts.version))}` : "";
983
+ const res = await deps.get(`${EXPORT_PATH}?id=${encodeURIComponent(workflowId)}${versionParam}`);
602
984
  if (!res.ok)
603
985
  return asErrorResult(res, false);
604
- const doc = JSON.stringify(res.data, null, 2);
986
+ const doc = opts.json
987
+ ? JSON.stringify(res.data, null, 2)
988
+ : workflowToYaml(res.data);
605
989
  if (opts.out) {
606
- deps.writeFile(opts.out, `${doc}\n`);
990
+ const text = doc.endsWith("\n") ? doc : `${doc}\n`;
991
+ deps.writeFile(opts.out, text);
607
992
  return { code: 0, lines: [`Wrote workflow contract to ${opts.out}.`] };
608
993
  }
609
- return { code: 0, lines: [doc] };
994
+ return { code: 0, lines: [doc.endsWith("\n") ? doc.slice(0, -1) : doc] };
610
995
  }
611
- export async function importFlow(file, opts, deps) {
996
+ export function parseVersionFlag(flags) {
997
+ const raw = flags["version"];
998
+ if (raw === undefined)
999
+ return undefined;
1000
+ if (typeof raw !== "string")
1001
+ throw new Error("--version requires a positive integer");
1002
+ const n = Number(raw);
1003
+ if (!Number.isInteger(n) || n < 1) {
1004
+ throw new Error(`--version must be a positive integer (got "${raw}")`);
1005
+ }
1006
+ return n;
1007
+ }
1008
+ export async function versionsFlow(workflowRef, opts, deps, channel = getChannel()) {
1009
+ let workflowId;
1010
+ try {
1011
+ workflowId = await resolveWorkflowId(workflowRef, deps);
1012
+ }
1013
+ catch (e) {
1014
+ return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1015
+ }
1016
+ const res = await deps.get(`${VERSIONS_PATH}?id=${encodeURIComponent(workflowId)}`);
1017
+ const unsupported = missingRouteLine(res, "workflow versions", channel);
1018
+ if (unsupported)
1019
+ return { code: 1, lines: [unsupported] };
1020
+ if (!res.ok)
1021
+ return asErrorResult(res, opts.json);
1022
+ if (opts.json)
1023
+ return { code: 0, lines: [JSON.stringify(res.data)] };
1024
+ const body = res.data;
1025
+ const versions = Array.isArray(body)
1026
+ ? body
1027
+ : (body.versions ?? []);
1028
+ return { code: 0, lines: [formatWorkflowVersions(versions)] };
1029
+ }
1030
+ function buildImportBody(file, opts, deps) {
612
1031
  let text;
613
1032
  try {
614
1033
  text = deps.readFile(file);
615
1034
  }
616
1035
  catch {
617
- return { code: 1, lines: [`Error: file not found: ${file}`] };
1036
+ return { error: { code: 1, lines: [`Error: file not found: ${file}`] } };
618
1037
  }
619
1038
  let parsed;
620
1039
  try {
621
- parsed = JSON.parse(text);
1040
+ parsed = parseWorkflowText(text);
622
1041
  }
623
1042
  catch (e) {
624
- const msg = e instanceof Error ? e.message : String(e);
625
- return { code: 1, lines: [`Error: ${file} is not valid JSON: ${msg}`] };
1043
+ const raw = e instanceof Error ? e.message : String(e);
1044
+ const detail = raw.replace(/^workflow file is not valid YAML or JSON:?\s*/, "");
1045
+ const suffix = detail ? `: ${detail}` : "";
1046
+ return { error: { code: 1, lines: [`Error: ${file} is not valid YAML or JSON${suffix}`] } };
626
1047
  }
627
1048
  if (!isRecord(parsed)) {
628
- return { code: 1, lines: [`Error: ${file} is not a workflow contract (expected a JSON object).`] };
1049
+ return {
1050
+ error: {
1051
+ code: 1,
1052
+ lines: [`Error: ${file} is not a workflow contract (expected a JSON object).`],
1053
+ },
1054
+ };
629
1055
  }
630
1056
  const body = { ...parsed };
631
1057
  delete body.dryRun;
@@ -637,17 +1063,25 @@ export async function importFlow(file, opts, deps) {
637
1063
  const updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined;
638
1064
  if (!updatedAt) {
639
1065
  return {
640
- code: 1,
641
- lines: [
642
- `Error: ${file} has no "updatedAt" anchor, so drift can't be detected. ` +
643
- `Re-export the workflow first (exodus workflow export <id> --out ${file}) and retry --update.`,
644
- ],
1066
+ error: {
1067
+ code: 1,
1068
+ lines: [
1069
+ `Error: ${file} has no "updatedAt" anchor, so drift can't be detected. ` +
1070
+ `Re-export the workflow first (exodus workflow export <id> --out ${file}) and retry --update.`,
1071
+ ],
1072
+ },
645
1073
  };
646
1074
  }
647
1075
  body.targetWorkflowId = opts.update;
648
1076
  body.expectedUpdatedAt = updatedAt;
649
1077
  }
650
- const res = await deps.post(IMPORT_PATH, body);
1078
+ return { body };
1079
+ }
1080
+ async function runImport(file, opts, deps) {
1081
+ const built = buildImportBody(file, { dryRun: opts.dryRun, update: opts.update }, deps);
1082
+ if ("error" in built)
1083
+ return built.error;
1084
+ const res = await deps.post(IMPORT_PATH, built.body);
651
1085
  if (!res.ok) {
652
1086
  return {
653
1087
  code: 1,
@@ -661,9 +1095,676 @@ export async function importFlow(file, opts, deps) {
661
1095
  code: 0,
662
1096
  lines: opts.json
663
1097
  ? [JSON.stringify(data)]
664
- : [formatImportSummary(data, { dryRun: opts.dryRun, update: !!opts.update })],
1098
+ : [
1099
+ formatImportSummary(data, {
1100
+ dryRun: opts.dryRun,
1101
+ update: !!opts.update,
1102
+ validate: opts.validate,
1103
+ }),
1104
+ ],
665
1105
  };
666
1106
  }
1107
+ export function triggerExpect(t) {
1108
+ return t.type === "event"
1109
+ ? { type: "event", event: t.event }
1110
+ : { type: "cron", cron: t.cron };
1111
+ }
1112
+ function triggerDetail(t) {
1113
+ return t.type === "event" ? t.event : t.cron;
1114
+ }
1115
+ const NO_TRIGGERS = "no triggers — add them via `exodus workflow export` / `import`";
1116
+ export function formatTriggers(triggers) {
1117
+ if (triggers.length === 0)
1118
+ return NO_TRIGGERS;
1119
+ return triggers
1120
+ .map((t, i) => {
1121
+ const state = t.enabled ? "enabled" : "disabled";
1122
+ return `${i + 1} · ${t.type} · ${triggerDetail(t)} · ${state}`;
1123
+ })
1124
+ .join("\n");
1125
+ }
1126
+ function triggerErrorResult(res, verb, json) {
1127
+ const missing = missingRouteLine(res, verb);
1128
+ if (missing) {
1129
+ return {
1130
+ code: 1,
1131
+ lines: json
1132
+ ? [JSON.stringify({ ok: false, status: res.status, error: missing })]
1133
+ : [missing],
1134
+ };
1135
+ }
1136
+ return asErrorResult(res, json);
1137
+ }
1138
+ async function fetchTriggers(workflowId, deps) {
1139
+ const res = await deps.get(`${EXPORT_PATH}?id=${encodeURIComponent(workflowId)}`);
1140
+ if (!res.ok)
1141
+ return { ok: false, res };
1142
+ const triggers = res.data.triggers ?? [];
1143
+ return { ok: true, triggers };
1144
+ }
1145
+ export async function triggersListFlow(workflowRef, opts, deps) {
1146
+ let workflowId;
1147
+ try {
1148
+ workflowId = await resolveWorkflowId(workflowRef, deps);
1149
+ }
1150
+ catch (e) {
1151
+ return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1152
+ }
1153
+ const fetched = await fetchTriggers(workflowId, deps);
1154
+ if (!fetched.ok)
1155
+ return triggerErrorResult(fetched.res, "workflow triggers", opts.json);
1156
+ if (opts.json) {
1157
+ return {
1158
+ code: 0,
1159
+ lines: [JSON.stringify(fetched.triggers.map((t, i) => ({ n: i + 1, ...t })))],
1160
+ };
1161
+ }
1162
+ return { code: 0, lines: [formatTriggers(fetched.triggers)] };
1163
+ }
1164
+ function triggerIndexError(message, triggers, json) {
1165
+ if (json) {
1166
+ return {
1167
+ code: 1,
1168
+ lines: [JSON.stringify({ ok: false, error: message, triggers: triggers.map((t, i) => ({ n: i + 1, ...t })) })],
1169
+ };
1170
+ }
1171
+ return { code: 1, lines: [message, "", formatTriggers(triggers)] };
1172
+ }
1173
+ export async function triggersSetEnabledFlow(workflowRef, n, enabled, opts, deps) {
1174
+ const verb = `workflow triggers ${enabled ? "enable" : "disable"}`;
1175
+ let workflowId;
1176
+ try {
1177
+ workflowId = await resolveWorkflowId(workflowRef, deps);
1178
+ }
1179
+ catch (e) {
1180
+ return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1181
+ }
1182
+ const fetched = await fetchTriggers(workflowId, deps);
1183
+ if (!fetched.ok)
1184
+ return triggerErrorResult(fetched.res, verb, opts.json);
1185
+ const triggers = fetched.triggers;
1186
+ const idx = n - 1;
1187
+ if (idx < 0 || idx >= triggers.length) {
1188
+ return triggerIndexError(`Trigger ${n} is out of range — this workflow has ${triggers.length} trigger(s).`, triggers, opts.json);
1189
+ }
1190
+ const res = await deps.post(TRIGGERS_SET_ENABLED_PATH, {
1191
+ workflowId,
1192
+ triggerIndex: idx,
1193
+ enabled,
1194
+ expect: triggerExpect(triggers[idx]),
1195
+ });
1196
+ if (!res.ok)
1197
+ return triggerErrorResult(res, verb, opts.json);
1198
+ if (opts.json)
1199
+ return { code: 0, lines: [JSON.stringify(res.data)] };
1200
+ return { code: 0, lines: [`Trigger ${n} ${enabled ? "enabled" : "disabled"}.`] };
1201
+ }
1202
+ export async function triggersFireFlow(workflowRef, opts, deps) {
1203
+ const verb = "workflow triggers fire";
1204
+ let workflowId;
1205
+ try {
1206
+ workflowId = await resolveWorkflowId(workflowRef, deps);
1207
+ }
1208
+ catch (e) {
1209
+ return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1210
+ }
1211
+ const fetched = await fetchTriggers(workflowId, deps);
1212
+ if (!fetched.ok)
1213
+ return triggerErrorResult(fetched.res, verb, opts.json);
1214
+ const triggers = fetched.triggers;
1215
+ if (triggers.length === 0) {
1216
+ return { code: 1, lines: [`This workflow has no triggers. ${NO_TRIGGERS}`] };
1217
+ }
1218
+ let idx;
1219
+ if (opts.n !== undefined) {
1220
+ idx = opts.n - 1;
1221
+ if (idx < 0 || idx >= triggers.length) {
1222
+ return triggerIndexError(`Trigger ${opts.n} is out of range — this workflow has ${triggers.length} trigger(s).`, triggers, opts.json);
1223
+ }
1224
+ }
1225
+ else if (triggers.length === 1) {
1226
+ idx = 0;
1227
+ }
1228
+ else {
1229
+ return triggerIndexError(`This workflow has ${triggers.length} triggers — specify which one to fire (e.g. \`fire 1\`).`, triggers, opts.json);
1230
+ }
1231
+ const t = triggers[idx];
1232
+ const n = idx + 1;
1233
+ if (t.type === "event" && (opts.text === undefined || opts.text === "")) {
1234
+ return {
1235
+ code: 1,
1236
+ lines: [
1237
+ `Trigger ${n} fires on the "${t.event}" event — pass --text with the input this run should carry.`,
1238
+ ],
1239
+ };
1240
+ }
1241
+ if (t.type === "cron" && opts.text !== undefined) {
1242
+ return {
1243
+ code: 1,
1244
+ lines: [`Trigger ${n} is a cron trigger ("${t.cron}") — it takes no --text.`],
1245
+ };
1246
+ }
1247
+ const res = await deps.post(TRIGGERS_FIRE_PATH, {
1248
+ workflowId,
1249
+ triggerIndex: idx,
1250
+ expect: triggerExpect(t),
1251
+ ...(opts.text !== undefined ? { text: opts.text } : {}),
1252
+ });
1253
+ if (!res.ok)
1254
+ return triggerErrorResult(res, verb, opts.json);
1255
+ const runId = res.data.runId;
1256
+ const base = { runId, workflowId };
1257
+ if (!opts.wait) {
1258
+ if (opts.json)
1259
+ return { code: 0, lines: [JSON.stringify(base)] };
1260
+ return {
1261
+ code: 0,
1262
+ lines: [
1263
+ `Trigger ${n} fired.`,
1264
+ `runId: ${runId}`,
1265
+ "This run executes as the workflow OWNER in the background.",
1266
+ `Poll: exodus workflow status --id ${runId}`,
1267
+ ],
1268
+ };
1269
+ }
1270
+ const startLines = [
1271
+ `Trigger ${n} fired.`,
1272
+ `runId: ${runId}`,
1273
+ "This run executes as the workflow OWNER in the background.",
1274
+ `Poll: exodus workflow status --id ${runId}`,
1275
+ ];
1276
+ if (!opts.json && opts.onProgressLine) {
1277
+ for (const line of startLines)
1278
+ opts.onProgressLine(line);
1279
+ }
1280
+ const waited = await waitForRun(runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base }, deps);
1281
+ if (opts.json)
1282
+ return waited;
1283
+ const prefix = opts.onProgressLine ? [] : startLines;
1284
+ return { code: waited.code, lines: [...prefix, ...waited.lines] };
1285
+ }
1286
+ export async function importFlow(file, opts, deps) {
1287
+ return runImport(file, opts, deps);
1288
+ }
1289
+ export async function validateFlow(file, opts, deps) {
1290
+ return runImport(file, { dryRun: true, json: opts.json, update: opts.update, validate: true }, deps);
1291
+ }
1292
+ export async function templatesListFlow(json, deps) {
1293
+ const res = await deps.get(TEMPLATES_PATH);
1294
+ if (!res.ok) {
1295
+ const missing = missingRouteLine(res, "workflow templates");
1296
+ if (missing)
1297
+ return { code: 1, lines: [missing] };
1298
+ return asErrorResult(res, json);
1299
+ }
1300
+ const data = res.data;
1301
+ if (json)
1302
+ return { code: 0, lines: [JSON.stringify(data)] };
1303
+ return { code: 0, lines: [formatTemplatesList(data.templates ?? [])] };
1304
+ }
1305
+ export async function templatesExportFlow(key, opts, deps) {
1306
+ const query = opts.json
1307
+ ? `?key=${encodeURIComponent(key)}&format=json`
1308
+ : `?key=${encodeURIComponent(key)}`;
1309
+ const res = await deps.getText(`${TEMPLATES_PATH}${query}`);
1310
+ if (!res.ok) {
1311
+ let parsedBody = res.data;
1312
+ try {
1313
+ parsedBody = JSON.parse(res.data);
1314
+ }
1315
+ catch {
1316
+ }
1317
+ const missing = missingRouteLine({ ...res, data: parsedBody }, "workflow templates export");
1318
+ if (missing)
1319
+ return { code: 1, lines: [missing] };
1320
+ return { code: 1, lines: [formatTextError(res)] };
1321
+ }
1322
+ const doc = res.data;
1323
+ if (opts.out) {
1324
+ const text = doc.endsWith("\n") ? doc : `${doc}\n`;
1325
+ deps.writeFile(opts.out, text);
1326
+ return { code: 0, lines: [`Wrote template "${key}" to ${opts.out}.`] };
1327
+ }
1328
+ return { code: 0, lines: [doc.endsWith("\n") ? doc.slice(0, -1) : doc] };
1329
+ }
1330
+ function formatTextError(res) {
1331
+ const body = res.data;
1332
+ let parsed;
1333
+ try {
1334
+ parsed = JSON.parse(body);
1335
+ }
1336
+ catch {
1337
+ const snippet = body.replace(/\s+/g, " ").trim().slice(0, 300);
1338
+ return formatError({ ok: false, status: res.status, data: snippet || "(empty response)" });
1339
+ }
1340
+ return formatError({ ok: false, status: res.status, data: parsed });
1341
+ }
1342
+ export async function schemaFlow(opts, deps) {
1343
+ const res = await deps.get(SCHEMA_PATH);
1344
+ if (!res.ok) {
1345
+ const missing = missingRouteLine(res, "workflow schema");
1346
+ if (missing)
1347
+ return { code: 1, lines: [missing] };
1348
+ return asErrorResult(res, opts.json);
1349
+ }
1350
+ if (opts.json)
1351
+ return { code: 0, lines: [JSON.stringify(res.data)] };
1352
+ const payload = isRecord(res.data) ? res.data : {};
1353
+ if (opts.kind !== undefined) {
1354
+ return formatSchemaFilter(payload, ["nodeKinds"], "kind", opts.kind);
1355
+ }
1356
+ if (opts.face !== undefined) {
1357
+ return formatSchemaFilter(payload, ["transformFaces"], "face", opts.face);
1358
+ }
1359
+ return { code: 0, lines: [formatSchema(payload)] };
1360
+ }
1361
+ function shortId(id) {
1362
+ return id.length <= 12 ? id : `${id.slice(0, 11)}…`;
1363
+ }
1364
+ export function formatAge(value, now = Date.now()) {
1365
+ if (value === undefined || value === null)
1366
+ return "-";
1367
+ const t = typeof value === "number" ? value : Date.parse(value);
1368
+ if (Number.isNaN(t))
1369
+ return "-";
1370
+ const sec = Math.max(0, Math.floor((now - t) / 1000));
1371
+ if (sec < 60)
1372
+ return `${sec}s`;
1373
+ const min = Math.floor(sec / 60);
1374
+ if (min < 60)
1375
+ return `${min}m`;
1376
+ const hr = Math.floor(min / 60);
1377
+ if (hr < 24)
1378
+ return `${hr}h`;
1379
+ return `${Math.floor(hr / 24)}d`;
1380
+ }
1381
+ export function parkBadge(pauseReason) {
1382
+ if (pauseReason === "taste")
1383
+ return "gate";
1384
+ if (pauseReason === "repair")
1385
+ return "repair";
1386
+ if (pauseReason === "slots")
1387
+ return "slots";
1388
+ return "legacy";
1389
+ }
1390
+ export function invocationBadge(row) {
1391
+ if (row.triggeredBy)
1392
+ return `trig:${row.triggeredBy.event ?? row.triggeredBy.type}`;
1393
+ if (row.invocationMode === "background")
1394
+ return "bg";
1395
+ return "";
1396
+ }
1397
+ const NO_INBOX = "Nothing waiting on you — the review inbox is empty.";
1398
+ export function formatInbox(rows, now = Date.now()) {
1399
+ if (rows.length === 0)
1400
+ return NO_INBOX;
1401
+ return table(["run", "workflow", "kind", "node", "via", "age"], rows.map((r) => [
1402
+ shortId(r._id),
1403
+ r.workflowName || "(unnamed)",
1404
+ parkBadge(r.pauseReason),
1405
+ r.pausedNodeId ?? "-",
1406
+ invocationBadge(r) || "-",
1407
+ formatAge(r.createdAt, now),
1408
+ ]));
1409
+ }
1410
+ export async function inboxFlow(json, deps) {
1411
+ const res = await deps.get(INBOX_PATH);
1412
+ if (!res.ok)
1413
+ return triggerErrorResult(res, "workflow inbox", json);
1414
+ const data = res.data;
1415
+ const rows = data.runs ?? [];
1416
+ return { code: 0, lines: json ? [JSON.stringify(rows)] : [formatInbox(rows)] };
1417
+ }
1418
+ function errLine(message, json, status) {
1419
+ return {
1420
+ code: 1,
1421
+ lines: [
1422
+ json
1423
+ ? JSON.stringify({ ok: false, ...(status ? { status } : {}), error: message })
1424
+ : message,
1425
+ ],
1426
+ };
1427
+ }
1428
+ function okLine(message, payload, json) {
1429
+ return { code: 0, lines: [json ? JSON.stringify(payload) : message] };
1430
+ }
1431
+ function routeErrorText(data, status) {
1432
+ if (isRecord(data)) {
1433
+ const err = data.error;
1434
+ if (typeof err === "string" && err)
1435
+ return err;
1436
+ if (isRecord(err) && typeof err.message === "string")
1437
+ return err.message;
1438
+ if (typeof data.message === "string" && data.message)
1439
+ return data.message;
1440
+ }
1441
+ return `HTTP ${status}`;
1442
+ }
1443
+ function asWorkflowRun(data) {
1444
+ return isRecord(data) && typeof data["_id"] === "string"
1445
+ ? data
1446
+ : undefined;
1447
+ }
1448
+ function describePark(run) {
1449
+ if (run.status !== "awaiting-review")
1450
+ return `status: ${run.status}`;
1451
+ switch (run.pauseReason) {
1452
+ case "taste":
1453
+ return "parked at a gate (taste review)";
1454
+ case "repair":
1455
+ return "parked for repair";
1456
+ case "slots":
1457
+ return "parked for slot answers";
1458
+ case "call":
1459
+ return "parked on a child workflow";
1460
+ default:
1461
+ return "parked at the cost gate (legacy — no text gate)";
1462
+ }
1463
+ }
1464
+ const PARK_LABEL = {
1465
+ taste: "a gate review",
1466
+ repair: "a repair",
1467
+ slots: "slot answers",
1468
+ call: "a child workflow",
1469
+ };
1470
+ async function preflightPark(runId, expected, verb, json, deps) {
1471
+ const res = await deps.get(`${STATUS_PATH}?runId=${encodeURIComponent(runId)}`);
1472
+ if (!res.ok)
1473
+ return { ok: false, result: triggerErrorResult(res, verb, json) };
1474
+ const run = asWorkflowRun(res.data);
1475
+ if (!run) {
1476
+ return { ok: false, result: errLine(`Could not read run ${runId}.`, json) };
1477
+ }
1478
+ if (run.status !== "awaiting-review" || run.pauseReason !== expected) {
1479
+ return {
1480
+ ok: false,
1481
+ result: errLine(`Run ${runId} is not parked for ${PARK_LABEL[expected]} — it is ${describePark(run)}.`, json),
1482
+ };
1483
+ }
1484
+ return { ok: true, run };
1485
+ }
1486
+ function gateCandidates(run) {
1487
+ const node = (run.nodes ?? []).find((x) => x.nodeId === run.pausedNodeId);
1488
+ if (!node)
1489
+ return [];
1490
+ const out = [];
1491
+ let n = 0;
1492
+ (node.outputs ?? []).forEach((a, idx) => {
1493
+ if (a.type === "text" && a.port === "selection") {
1494
+ n += 1;
1495
+ out.push({ n, outputIndex: idx, text: a.text, humanEdited: !!a.humanEdited });
1496
+ }
1497
+ });
1498
+ return out;
1499
+ }
1500
+ function formatCandidates(cands) {
1501
+ if (cands.length === 0)
1502
+ return " (this gate has no selection candidates)";
1503
+ return cands
1504
+ .map((c) => ` ${c.n}. ${truncateText(c.text, 200)}${c.humanEdited ? " (edited)" : ""}`)
1505
+ .join("\n");
1506
+ }
1507
+ function rangeError(num, count, json) {
1508
+ const range = count > 0 ? ` (valid: 1–${count})` : "";
1509
+ return errLine(`Candidate ${num} is out of range — this gate has ${count} candidate${count === 1 ? "" : "s"}${range}.`, json);
1510
+ }
1511
+ export async function gateShowFlow(runId, opts, deps) {
1512
+ const pf = await preflightPark(runId, "taste", "workflow gate", opts.json, deps);
1513
+ if (!pf.ok)
1514
+ return pf.result;
1515
+ const cands = gateCandidates(pf.run);
1516
+ if (opts.json) {
1517
+ return {
1518
+ code: 0,
1519
+ lines: [
1520
+ JSON.stringify({
1521
+ runId,
1522
+ pausedNodeId: pf.run.pausedNodeId,
1523
+ candidates: cands.map((c) => ({ n: c.n, text: c.text, humanEdited: c.humanEdited })),
1524
+ }),
1525
+ ],
1526
+ };
1527
+ }
1528
+ return {
1529
+ code: 0,
1530
+ lines: [
1531
+ `Gate — ${pf.run.workflowName}`,
1532
+ `runId: ${runId}`,
1533
+ `gate node: ${pf.run.pausedNodeId ?? "-"}`,
1534
+ "",
1535
+ `Candidates (${cands.length}):`,
1536
+ formatCandidates(cands),
1537
+ "",
1538
+ `Pick: exodus workflow gate ${runId} pick 1,2`,
1539
+ `Edit: exodus workflow gate ${runId} edit 1 --text "..."`,
1540
+ `Approve: exodus workflow gate ${runId} approve --wait`,
1541
+ `Reject: exodus workflow gate ${runId} reject --reason "..."`,
1542
+ ],
1543
+ };
1544
+ }
1545
+ export async function gatePickFlow(runId, numbers, opts, deps) {
1546
+ const pf = await preflightPark(runId, "taste", "workflow gate pick", opts.json, deps);
1547
+ if (!pf.ok)
1548
+ return pf.result;
1549
+ const cands = gateCandidates(pf.run);
1550
+ const seen = new Set();
1551
+ for (const num of numbers) {
1552
+ if (!Number.isInteger(num) || num < 1 || num > cands.length) {
1553
+ return rangeError(num, cands.length, opts.json);
1554
+ }
1555
+ if (seen.has(num))
1556
+ return errLine(`Candidate ${num} is listed twice.`, opts.json);
1557
+ seen.add(num);
1558
+ }
1559
+ const selectedIndices = numbers.map((num) => num - 1);
1560
+ const res = await deps.post(GATE_PICK_PATH, {
1561
+ runId,
1562
+ nodeId: pf.run.pausedNodeId,
1563
+ selectedIndices,
1564
+ });
1565
+ if (!res.ok)
1566
+ return triggerErrorResult(res, "workflow gate pick", opts.json);
1567
+ 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);
1568
+ }
1569
+ export async function gateEditFlow(runId, n, sources, opts, deps) {
1570
+ const provided = [sources.text, sources.file, sources.stdin].filter((x) => x !== undefined);
1571
+ if (provided.length === 0) {
1572
+ return errLine("Provide the replacement text via one of --text, --file <path>, or piped stdin.", opts.json);
1573
+ }
1574
+ if (provided.length > 1) {
1575
+ return errLine("Provide the replacement text via exactly one of --text, --file, or stdin — not several.", opts.json);
1576
+ }
1577
+ let text;
1578
+ if (sources.text !== undefined) {
1579
+ text = sources.text;
1580
+ }
1581
+ else if (sources.file !== undefined) {
1582
+ try {
1583
+ text = deps.readFile(sources.file);
1584
+ }
1585
+ catch (e) {
1586
+ const msg = e instanceof Error ? e.message : String(e);
1587
+ return errLine(`Could not read file "${sources.file}": ${msg}`, opts.json);
1588
+ }
1589
+ }
1590
+ else {
1591
+ text = sources.stdin;
1592
+ }
1593
+ const pf = await preflightPark(runId, "taste", "workflow gate edit", opts.json, deps);
1594
+ if (!pf.ok)
1595
+ return pf.result;
1596
+ const cands = gateCandidates(pf.run);
1597
+ const cand = cands.find((c) => c.n === n);
1598
+ if (!cand)
1599
+ return rangeError(n, cands.length, opts.json);
1600
+ const res = await deps.post(GATE_EDIT_PATH, {
1601
+ runId,
1602
+ nodeId: pf.run.pausedNodeId,
1603
+ outputIndex: cand.outputIndex,
1604
+ text,
1605
+ });
1606
+ if (!res.ok)
1607
+ return triggerErrorResult(res, "workflow gate edit", opts.json);
1608
+ return okLine(`Edited candidate ${n} at ${pf.run.pausedNodeId}.`, { ok: true, runId, nodeId: pf.run.pausedNodeId, outputIndex: cand.outputIndex }, opts.json);
1609
+ }
1610
+ export async function gatePushFlow(runId, message, opts, deps) {
1611
+ const pf = await preflightPark(runId, "taste", "workflow gate push", opts.json, deps);
1612
+ if (!pf.ok)
1613
+ return pf.result;
1614
+ const session = (pf.run.sessions ?? []).find((s) => s.nodeId === pf.run.pausedNodeId);
1615
+ if (!session)
1616
+ return errLine("This gate has no live session — nothing to push to.", opts.json);
1617
+ if (!deps.postDashboard) {
1618
+ return errLine("gate push is unavailable here (no dashboard client).", opts.json);
1619
+ }
1620
+ const chat = await deps.postDashboard(CHAT_PATH, { sessionId: session.sessionId, text: message }, { timeoutMs: CHAT_TIMEOUT_MS });
1621
+ if (!chat.ok) {
1622
+ const err = routeErrorText(chat.data, chat.status);
1623
+ return {
1624
+ code: 1,
1625
+ lines: opts.json
1626
+ ? [JSON.stringify({ ok: false, status: chat.status, error: err })]
1627
+ : [`exodus workflow gate push: ${err} (HTTP ${chat.status})`],
1628
+ };
1629
+ }
1630
+ const res = await deps.post(GATE_APPEND_PATH, { runId, nodeId: pf.run.pausedNodeId });
1631
+ if (!res.ok)
1632
+ return triggerErrorResult(res, "workflow gate push", opts.json);
1633
+ const after = await preflightPark(runId, "taste", "workflow gate push", opts.json, deps);
1634
+ const cands = after.ok ? gateCandidates(after.run) : [];
1635
+ const newest = cands[cands.length - 1];
1636
+ if (opts.json) {
1637
+ return {
1638
+ code: 0,
1639
+ lines: [
1640
+ JSON.stringify({
1641
+ ok: true,
1642
+ runId,
1643
+ nodeId: pf.run.pausedNodeId,
1644
+ candidate: newest ? { n: newest.n, text: newest.text } : null,
1645
+ }),
1646
+ ],
1647
+ };
1648
+ }
1649
+ const lines = [`Pushed to the gate — banked a new candidate at ${pf.run.pausedNodeId}.`];
1650
+ if (newest) {
1651
+ lines.push("", `New candidate:`, ` ${newest.n}. ${truncateText(newest.text, 200)}`);
1652
+ }
1653
+ return { code: 0, lines };
1654
+ }
1655
+ export async function gateApproveFlow(runId, opts, deps) {
1656
+ const pf = await preflightPark(runId, "taste", "workflow gate approve", opts.json, deps);
1657
+ if (!pf.ok)
1658
+ return pf.result;
1659
+ const res = await deps.post(APPROVE_PATH, { runId });
1660
+ if (!res.ok)
1661
+ return triggerErrorResult(res, "workflow gate approve", opts.json);
1662
+ const triggerRunId = res.data.triggerRunId;
1663
+ return resumeAndMaybeWait(runId, triggerRunId, ["Gate approved — the run resumes."], opts, deps);
1664
+ }
1665
+ export async function gateRejectFlow(runId, opts, deps) {
1666
+ const pf = await preflightPark(runId, "taste", "workflow gate reject", opts.json, deps);
1667
+ if (!pf.ok)
1668
+ return pf.result;
1669
+ const res = await deps.post(CANCEL_PATH, {
1670
+ runId,
1671
+ ...(opts.reason !== undefined ? { reason: opts.reason } : {}),
1672
+ });
1673
+ if (!res.ok)
1674
+ return triggerErrorResult(res, "workflow gate reject", opts.json);
1675
+ return okLine(`Gate rejected — run ${runId} canceled.`, { ok: true, runId }, opts.json);
1676
+ }
1677
+ export async function repairFlow(runId, action, opts, deps) {
1678
+ const verb = `workflow repair ${action}`;
1679
+ const pf = await preflightPark(runId, "repair", verb, opts.json, deps);
1680
+ if (!pf.ok)
1681
+ return pf.result;
1682
+ if (action === "kill") {
1683
+ const res = await deps.post(CANCEL_PATH, { runId });
1684
+ if (!res.ok)
1685
+ return triggerErrorResult(res, verb, opts.json);
1686
+ return okLine(`Repair killed — run ${runId} canceled.`, { ok: true, runId }, opts.json);
1687
+ }
1688
+ const path = action === "retry" ? REPAIR_RETRY_PATH : REPAIR_SKIP_PATH;
1689
+ const res = await deps.post(path, { runId });
1690
+ if (!res.ok)
1691
+ return triggerErrorResult(res, verb, opts.json);
1692
+ const triggerRunId = res.data.triggerRunId;
1693
+ return resumeAndMaybeWait(runId, triggerRunId, [`Repair ${action} started — the run resumes.`], opts, deps);
1694
+ }
1695
+ async function resumeAndMaybeWait(runId, triggerRunId, headline, opts, deps) {
1696
+ const base = { runId, triggerRunId };
1697
+ const startLines = [
1698
+ ...headline,
1699
+ `runId: ${runId}`,
1700
+ `triggerRunId: ${triggerRunId ?? "-"}`,
1701
+ `Poll: exodus workflow status --id ${runId}`,
1702
+ ];
1703
+ if (!opts.wait) {
1704
+ if (opts.json)
1705
+ return { code: 0, lines: [JSON.stringify(base)] };
1706
+ return { code: 0, lines: startLines };
1707
+ }
1708
+ if (!opts.json && opts.onProgressLine) {
1709
+ for (const line of startLines)
1710
+ opts.onProgressLine(line);
1711
+ }
1712
+ const waited = await waitForRun(runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base }, deps);
1713
+ if (opts.json)
1714
+ return waited;
1715
+ const prefix = opts.onProgressLine ? [] : startLines;
1716
+ return { code: waited.code, lines: [...prefix, ...waited.lines] };
1717
+ }
1718
+ export function parseSlotFlags(args) {
1719
+ const values = {};
1720
+ for (let i = 0; i < args.length; i++) {
1721
+ const arg = args[i];
1722
+ let raw;
1723
+ if (arg === "--slot") {
1724
+ raw = args[i + 1];
1725
+ i++;
1726
+ }
1727
+ else if (arg.startsWith("--slot=")) {
1728
+ raw = arg.slice("--slot=".length);
1729
+ }
1730
+ else {
1731
+ continue;
1732
+ }
1733
+ if (!raw)
1734
+ throw new Error("--slot requires key=value");
1735
+ const eq = raw.indexOf("=");
1736
+ if (eq <= 0)
1737
+ throw new Error(`--slot must be key=value (got "${raw}")`);
1738
+ const key = raw.slice(0, eq).trim();
1739
+ if (!key)
1740
+ throw new Error(`--slot must include a key (got "${raw}")`);
1741
+ values[key] = raw.slice(eq + 1);
1742
+ }
1743
+ return values;
1744
+ }
1745
+ export async function answerFlow(runId, values, opts, deps) {
1746
+ const pf = await preflightPark(runId, "slots", "workflow answer", opts.json, deps);
1747
+ if (!pf.ok)
1748
+ return pf.result;
1749
+ const pending = pf.run.pendingSlots ?? [];
1750
+ if (Object.keys(values).length === 0) {
1751
+ if (opts.json) {
1752
+ return { code: 0, lines: [JSON.stringify({ runId, pendingSlots: pending })] };
1753
+ }
1754
+ const lines = [`Run ${runId} is waiting on ${pending.length} slot answer(s):`];
1755
+ for (const s of pending) {
1756
+ const label = s.label ? ` — ${s.label}` : "";
1757
+ const hint = s.hint ? ` (${s.hint})` : "";
1758
+ lines.push(` ${s.id}${label}${hint}`);
1759
+ }
1760
+ lines.push("", `Answer: exodus workflow answer ${runId} --slot ${pending[0]?.id ?? "key"}=value`);
1761
+ return { code: 0, lines };
1762
+ }
1763
+ const res = await deps.post(ANSWER_PATH, { runId, values });
1764
+ if (!res.ok)
1765
+ return triggerErrorResult(res, "workflow answer", opts.json);
1766
+ return okLine(`Answered ${Object.keys(values).length} slot(s) for run ${runId} — the child resumes.`, { ok: true, runId, values }, opts.json);
1767
+ }
667
1768
  function flagString(flags, name) {
668
1769
  const v = flags[name];
669
1770
  return typeof v === "string" ? v : undefined;
@@ -692,6 +1793,37 @@ async function printResult(result) {
692
1793
  if (result.code !== 0)
693
1794
  process.exit(result.code);
694
1795
  }
1796
+ function parsePickNumbers(raw) {
1797
+ if (raw === undefined)
1798
+ return undefined;
1799
+ const parts = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1800
+ if (parts.length === 0)
1801
+ return undefined;
1802
+ const nums = [];
1803
+ for (const p of parts) {
1804
+ if (!/^\d+$/.test(p))
1805
+ return undefined;
1806
+ nums.push(Number(p));
1807
+ }
1808
+ return nums;
1809
+ }
1810
+ async function readAllStdin() {
1811
+ const chunks = [];
1812
+ for await (const chunk of process.stdin)
1813
+ chunks.push(chunk);
1814
+ return Buffer.concat(chunks).toString("utf-8");
1815
+ }
1816
+ async function maybeReadStdin(flags) {
1817
+ if (flagString(flags, "text") !== undefined || flagString(flags, "file") !== undefined) {
1818
+ return undefined;
1819
+ }
1820
+ if (process.stdin.isTTY)
1821
+ return undefined;
1822
+ const raw = await readAllStdin();
1823
+ if (raw.length === 0)
1824
+ return undefined;
1825
+ return raw.endsWith("\n") ? raw.slice(0, -1) : raw;
1826
+ }
695
1827
  export async function run(flags) {
696
1828
  const positional = parsePositional();
697
1829
  const [sub, ...rest] = positional;
@@ -714,6 +1846,36 @@ export async function run(flags) {
714
1846
  if (sub === "bots") {
715
1847
  return printResult(await botsFlow({ category: flagString(flags, "category"), slug: flagString(flags, "slug"), json }, defaultDeps));
716
1848
  }
1849
+ if (sub === "templates") {
1850
+ const action = rest[0];
1851
+ if (!action || action === "list") {
1852
+ return printResult(await templatesListFlow(json, defaultDeps));
1853
+ }
1854
+ if (action === "export") {
1855
+ const key = rest[1];
1856
+ if (!key) {
1857
+ console.error("Error: workflow templates export requires <key>.");
1858
+ console.log("Usage: exodus workflow templates export <key> [--out <file>] [--json]");
1859
+ process.exit(1);
1860
+ }
1861
+ return printResult(await templatesExportFlow(key, { out: flagString(flags, "out"), json }, defaultDeps));
1862
+ }
1863
+ console.error(`Unknown templates action: "${action}"`);
1864
+ console.log("Usage: exodus workflow templates [list] | exodus workflow templates export <key>");
1865
+ process.exit(1);
1866
+ }
1867
+ if (sub === "schema") {
1868
+ return printResult(await schemaFlow({ json, kind: flagString(flags, "kind"), face: flagString(flags, "face") }, defaultDeps));
1869
+ }
1870
+ if (sub === "validate") {
1871
+ const file = rest[0];
1872
+ if (!file) {
1873
+ console.error("Error: workflow validate requires <file>.");
1874
+ console.log("Usage: exodus workflow validate <file> [--update <workflowId>] [--json]");
1875
+ process.exit(1);
1876
+ }
1877
+ return printResult(await validateFlow(file, { json, update: flagString(flags, "update") }, defaultDeps));
1878
+ }
717
1879
  if (sub === "run") {
718
1880
  const workflowRef = rest[0];
719
1881
  if (!workflowRef) {
@@ -722,8 +1884,10 @@ export async function run(flags) {
722
1884
  process.exit(1);
723
1885
  }
724
1886
  let inputs;
1887
+ let terminalNodeIds;
725
1888
  try {
726
1889
  inputs = parseInputFlags(process.argv.slice(3), defaultDeps.readFile);
1890
+ terminalNodeIds = parseTerminalFlags(process.argv.slice(3));
727
1891
  }
728
1892
  catch (e) {
729
1893
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -731,6 +1895,7 @@ export async function run(flags) {
731
1895
  }
732
1896
  return printResult(await runFlow(workflowRef, {
733
1897
  inputs,
1898
+ terminalNodeIds,
734
1899
  wait: flags["wait"] === true,
735
1900
  json,
736
1901
  onProgressLine: (line) => console.log(line),
@@ -739,14 +1904,31 @@ export async function run(flags) {
739
1904
  if (sub === "status") {
740
1905
  return printResult(await statusFlow({ id: flagString(flags, "id"), json }, defaultDeps));
741
1906
  }
1907
+ if (sub === "versions") {
1908
+ const workflowRef = rest[0];
1909
+ if (!workflowRef) {
1910
+ console.error("Error: workflow versions requires <workflowId|name>.");
1911
+ console.log("Usage: exodus workflow versions <workflowId|name> [--json]");
1912
+ process.exit(1);
1913
+ }
1914
+ return printResult(await versionsFlow(workflowRef, { json }, defaultDeps));
1915
+ }
742
1916
  if (sub === "export") {
743
1917
  const workflowRef = rest[0];
744
1918
  if (!workflowRef) {
745
1919
  console.error("Error: workflow export requires <workflowId|name>.");
746
- console.log("Usage: exodus workflow export <workflowId|name> [--out <file>]");
1920
+ console.log("Usage: exodus workflow export <workflowId|name> [--out <file>] [--version <n>]");
1921
+ process.exit(1);
1922
+ }
1923
+ let version;
1924
+ try {
1925
+ version = parseVersionFlag(flags);
1926
+ }
1927
+ catch (e) {
1928
+ console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
747
1929
  process.exit(1);
748
1930
  }
749
- return printResult(await exportFlow(workflowRef, { out: flagString(flags, "out") }, defaultDeps));
1931
+ return printResult(await exportFlow(workflowRef, { out: flagString(flags, "out"), json, version }, defaultDeps));
750
1932
  }
751
1933
  if (sub === "import") {
752
1934
  const file = rest[0];
@@ -757,7 +1939,135 @@ export async function run(flags) {
757
1939
  }
758
1940
  return printResult(await importFlow(file, { dryRun: flags["dry-run"] === true, json, update: flagString(flags, "update") }, defaultDeps));
759
1941
  }
1942
+ if (sub === "inbox") {
1943
+ return printResult(await inboxFlow(json, defaultDeps));
1944
+ }
1945
+ if (sub === "gate") {
1946
+ const runId = rest[0];
1947
+ if (!runId) {
1948
+ console.error("Error: workflow gate requires <runId>.");
1949
+ console.log("Usage: exodus workflow gate <runId> [pick <n,..> | edit <n> | push \"msg\" | approve | reject]");
1950
+ process.exit(1);
1951
+ }
1952
+ const action = rest[1];
1953
+ if (!action)
1954
+ return printResult(await gateShowFlow(runId, { json }, defaultDeps));
1955
+ if (action === "pick") {
1956
+ const numbers = parsePickNumbers(rest[2]);
1957
+ if (!numbers) {
1958
+ console.error(`Error: gate pick needs comma-separated 1-based numbers (e.g. \`pick 3,7\`).`);
1959
+ process.exit(1);
1960
+ }
1961
+ return printResult(await gatePickFlow(runId, numbers, { json }, defaultDeps));
1962
+ }
1963
+ if (action === "edit") {
1964
+ const n = parseTriggerIndex(rest[2]);
1965
+ if (n === undefined) {
1966
+ console.error("Error: gate edit needs a 1-based candidate number <n>.");
1967
+ console.log(`Usage: exodus workflow gate <runId> edit <n> [--text "..." | --file <path> | (stdin)]`);
1968
+ process.exit(1);
1969
+ }
1970
+ const stdin = await maybeReadStdin(flags);
1971
+ return printResult(await gateEditFlow(runId, n, { text: flagString(flags, "text"), file: flagString(flags, "file"), stdin }, { json }, defaultDeps));
1972
+ }
1973
+ if (action === "push") {
1974
+ const message = rest[2];
1975
+ if (!message) {
1976
+ console.error('Error: gate push needs a message (e.g. `push "make it punchier"`).');
1977
+ process.exit(1);
1978
+ }
1979
+ return printResult(await gatePushFlow(runId, message, { json }, defaultDeps));
1980
+ }
1981
+ if (action === "approve") {
1982
+ return printResult(await gateApproveFlow(runId, { wait: flags["wait"] === true, json, onProgressLine: (line) => console.log(line) }, defaultDeps));
1983
+ }
1984
+ if (action === "reject") {
1985
+ return printResult(await gateRejectFlow(runId, { reason: flagString(flags, "reason"), json }, defaultDeps));
1986
+ }
1987
+ console.error(`Error: unknown gate action "${action}" (expected pick, edit, push, approve, or reject).`);
1988
+ process.exit(1);
1989
+ }
1990
+ if (sub === "repair") {
1991
+ const runId = rest[0];
1992
+ if (!runId) {
1993
+ console.error("Error: workflow repair requires <runId>.");
1994
+ console.log("Usage: exodus workflow repair <runId> retry|skip|kill [--wait]");
1995
+ process.exit(1);
1996
+ }
1997
+ const action = rest[1];
1998
+ if (action !== "retry" && action !== "skip" && action !== "kill") {
1999
+ console.error(`Error: workflow repair needs an action (retry, skip, or kill)${action ? ` — got "${action}"` : ""}.`);
2000
+ console.log("Usage: exodus workflow repair <runId> retry|skip|kill [--wait]");
2001
+ process.exit(1);
2002
+ }
2003
+ return printResult(await repairFlow(runId, action, { wait: flags["wait"] === true, json, onProgressLine: (line) => console.log(line) }, defaultDeps));
2004
+ }
2005
+ if (sub === "answer") {
2006
+ const runId = rest[0];
2007
+ if (!runId) {
2008
+ console.error("Error: workflow answer requires <runId>.");
2009
+ console.log("Usage: exodus workflow answer <runId> --slot key=value [--slot key=value ...]");
2010
+ process.exit(1);
2011
+ }
2012
+ let values;
2013
+ try {
2014
+ values = parseSlotFlags(process.argv.slice(3));
2015
+ }
2016
+ catch (e) {
2017
+ console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
2018
+ process.exit(1);
2019
+ }
2020
+ return printResult(await answerFlow(runId, values, { json }, defaultDeps));
2021
+ }
2022
+ if (sub === "triggers") {
2023
+ const workflowRef = rest[0];
2024
+ if (!workflowRef) {
2025
+ console.error("Error: workflow triggers requires <workflowId|name>.");
2026
+ console.log("Usage: exodus workflow triggers <workflowId|name> [enable|disable <n> | fire [<n>]] [--json]");
2027
+ process.exit(1);
2028
+ }
2029
+ const action = rest[1];
2030
+ if (!action)
2031
+ return printResult(await triggersListFlow(workflowRef, { json }, defaultDeps));
2032
+ if (action === "enable" || action === "disable") {
2033
+ const n = parseTriggerIndex(rest[2]);
2034
+ if (n === undefined) {
2035
+ console.error(`Error: workflow triggers ${action} requires a 1-based trigger number <n>.`);
2036
+ console.log(`Usage: exodus workflow triggers <workflowId|name> ${action} <n>`);
2037
+ process.exit(1);
2038
+ }
2039
+ return printResult(await triggersSetEnabledFlow(workflowRef, n, action === "enable", { json }, defaultDeps));
2040
+ }
2041
+ if (action === "fire") {
2042
+ let n;
2043
+ if (rest[2] !== undefined) {
2044
+ n = parseTriggerIndex(rest[2]);
2045
+ if (n === undefined) {
2046
+ console.error(`Error: trigger number must be a positive integer (got "${rest[2]}").`);
2047
+ process.exit(1);
2048
+ }
2049
+ }
2050
+ return printResult(await triggersFireFlow(workflowRef, {
2051
+ n,
2052
+ text: flagString(flags, "text"),
2053
+ wait: flags["wait"] === true,
2054
+ json,
2055
+ onProgressLine: (line) => console.log(line),
2056
+ }, defaultDeps));
2057
+ }
2058
+ console.error(`Error: unknown triggers action "${action}" (expected enable, disable, or fire).`);
2059
+ console.log("Usage: exodus workflow triggers <workflowId|name> [enable|disable <n> | fire [<n>]] [--json]");
2060
+ process.exit(1);
2061
+ }
760
2062
  console.error(`Unknown subcommand: "${sub}"\n`);
761
2063
  console.log(helpText);
762
2064
  process.exit(1);
763
2065
  }
2066
+ function parseTriggerIndex(raw) {
2067
+ if (raw === undefined)
2068
+ return undefined;
2069
+ if (!/^\d+$/.test(raw))
2070
+ return undefined;
2071
+ const n = Number(raw);
2072
+ return n >= 1 ? n : undefined;
2073
+ }