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

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 path from "node:path";
3
+ import nodePath from "node:path";
2
4
  import { apiGet, apiGetText, apiPost, apiPostDashboard, getDashboardUrl, } from "../lib/client.js";
3
- import { formatError } from "../lib/format.js";
5
+ import { formatApiError } from "../lib/format.js";
4
6
  import { pollUntilDone } from "../lib/poll.js";
7
+ import { normalizeRunStatus, runStatusLabel, storedWorkflowStatusForms, TERMINAL_RUN_STATUSES, } from "../lib/runStatus.js";
5
8
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
6
9
  import { missingRouteLine } from "../lib/route-support.js";
7
10
  import { getChannel } from "../lib/channel.js";
@@ -15,8 +18,8 @@ Usage:
15
18
  exodus workflow templates [list] [--json]
16
19
  exodus workflow templates export <key> [--out <file>] [--json]
17
20
  exodus workflow schema [--kind <kind>] [--face <face>] [--json]
18
- exodus workflow run <workflowId|name> [--input key=value ...] [--terminal <nodeId> ...] [--wait] [--json]
19
- exodus workflow status [--id <runId>] [--json]
21
+ exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=<path> ...] [--terminal <nodeId> ...] [--wait] [--out <dir>] [--json]
22
+ exodus workflow status [--id <runId>] [--out <dir>] [--json]
20
23
  exodus workflow versions <workflowId|name> [--json]
21
24
  exodus workflow export <workflowId|name> [--version <n>] [--out <file>] [--json]
22
25
  exodus workflow validate <file> [--update <workflowId>] [--json]
@@ -26,12 +29,11 @@ Usage:
26
29
  exodus workflow triggers <workflowId|name> disable <n> [--json]
27
30
  exodus workflow triggers <workflowId|name> fire [<n>] [--text "..."] [--wait] [--json]
28
31
  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]
32
+ exodus workflow checkpoint <runId> [show] [--json]
33
+ exodus workflow checkpoint <runId> edit <n> [--text "..." | --file <path> | (stdin)] [--json]
34
+ exodus workflow checkpoint <runId> approve [--reject <n[,n..]>] [--wait] [--json]
35
+ exodus workflow checkpoint <runId> retry [--wait] [--json]
36
+ exodus workflow checkpoint <runId> cancel [--reason "..."] [--json]
35
37
  exodus workflow repair <runId> retry|skip|kill [--wait] [--json]
36
38
  exodus workflow answer <runId> --slot key=value [--slot key=value ...] [--json]
37
39
 
@@ -43,13 +45,37 @@ Flags:
43
45
  --input key=@path loads the value from a file (path is
44
46
  resolved from the current directory); --input key=@@text
45
47
  keeps a leading "@" as a literal character.
48
+ FILE fields (an Asset Input — "workflow describe" shows
49
+ them as source "asset") take a local file path instead:
50
+ --input hero=./photos/hero.png. The CLI uploads the file
51
+ and sends the stored asset in its place; a leading "@"
52
+ is accepted and ignored. Pass an asset id from an
53
+ earlier upload to reuse it. Accepted files: image PNG,
54
+ JPEG, WebP, GIF (≤15MB) · video MP4, MOV, WebM (≤200MB)
55
+ · audio MP3, M4A, WAV, OGG (≤50MB) · document PDF, TXT,
56
+ MD, DOC, DOCX (≤25MB).
57
+ --fill <name> (run) Launch from a saved fill — a named set of inputs
58
+ saved on THIS brand's copy of the workflow. Its values
59
+ become the run's inputs; any --input you also pass wins
60
+ for that one key, so a fill is a starting point, not a
61
+ lock. Names are exact (case-sensitive); an unknown one
62
+ is rejected before the run starts. Fills are created and
63
+ named in the dashboard's run dialog.
46
64
  --terminal <nodeId> (run) Repeatable. Scope the run to the upstream closure
47
65
  of these end node(s) — only nodes feeding a picked
48
66
  terminal execute; the rest are recorded out-of-scope.
49
67
  Omit to run the whole graph.
50
- --wait Poll until the workflow run reaches a terminal status
68
+ --wait Poll until the workflow run reaches a terminal status.
69
+ (checkpoint retry) Waits until the step's fresh output
70
+ is ready and the run parks again — that IS its finish
71
+ line; a redo never runs the workflow to completion.
51
72
  --id <runId> Workflow run id for status detail
52
73
  --out <file> Write the export to a file instead of stdout
74
+ --out <dir> (run --wait / status --id) Save every delivered output
75
+ of the finished run into this directory (created if
76
+ missing) and print the paths — text as .md, storyboards
77
+ and frame sets as .json, images/video/audio downloaded.
78
+ Unfulfilled slots are reported, never written.
53
79
  --version <n> (export) Export a saved historical version instead of the
54
80
  current head. <n> is the real 1-based version id from
55
81
  "workflow versions" (a positive integer). A version
@@ -69,9 +95,17 @@ Flags:
69
95
  use validate as the standalone front door.
70
96
  --text "..." (triggers fire) The input an EVENT trigger's run carries.
71
97
  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.
98
+ (checkpoint edit) The replacement copy for one output.
99
+ --file <path> (checkpoint edit) Load the replacement copy from a file.
100
+ --reason "..." (checkpoint cancel) Optional reason recorded on the
101
+ cancel.
102
+ --reject <n[,n..]> (checkpoint approve) Only for a checkpoint that is
103
+ reviewing a Splitter fan-out item by item. Drops those
104
+ ITEM numbers (the "Item N of M" headings in the show
105
+ listing, 1-based) and approves the rest. Rejecting is
106
+ filtering, not failing — the run carries on with the
107
+ survivors. Rejecting every item is refused: cancel
108
+ instead.
75
109
  --slot key=value (answer) Repeatable. One answer per pending slot.
76
110
  --kind <kind> (schema) Print just one node kind's ports + config rules
77
111
  --face <face> (schema) Print just one transform face's spec
@@ -85,11 +119,18 @@ Examples:
85
119
  exodus workflow templates export complete-ad-set --out my.yaml
86
120
  exodus workflow schema
87
121
  exodus workflow schema --kind transform
88
- exodus workflow schema --face splitter
122
+ exodus workflow schema --kind splitter
123
+ exodus workflow schema --kind collector
124
+ exodus workflow schema --face collector
89
125
  exodus workflow run "Launch Flow" --input brief="new offer" --wait
90
126
  exodus workflow run "Launch Flow" --input brief=@brief.txt
127
+ exodus workflow run "Product Shots" --input hero=./photos/hero.png --wait
128
+ exodus workflow run "Launch Flow" --fill "Weekly promo" --wait
129
+ exodus workflow run "Launch Flow" --fill "Weekly promo" --input brief="new offer"
91
130
  exodus workflow run "Launch Flow" --terminal bot-3 --terminal image-2
131
+ exodus workflow run "Launch Flow" --wait --out ./deliverables
92
132
  exodus workflow status --id wr_123
133
+ exodus workflow status --id wr_123 --out ./deliverables
93
134
  exodus workflow versions "Launch Flow"
94
135
  exodus workflow export "Launch Flow" --out workflow.yaml
95
136
  exodus workflow export "Launch Flow" --version 3 --out v3.yaml
@@ -101,12 +142,12 @@ Examples:
101
142
  exodus workflow triggers "Winner Flywheel" enable 1
102
143
  exodus workflow triggers "Winner Flywheel" fire 1 --text "new offer" --wait
103
144
  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"
145
+ exodus workflow checkpoint wr_123
146
+ exodus workflow checkpoint wr_123 edit 1 --text "tighter opener"
147
+ exodus workflow checkpoint wr_123 approve --wait
148
+ exodus workflow checkpoint wr_123 approve --reject 2,5 --wait
149
+ exodus workflow checkpoint wr_123 retry --wait
150
+ exodus workflow checkpoint wr_123 cancel --reason "wrong direction"
110
151
  exodus workflow repair wr_123 retry --wait
111
152
  exodus workflow answer wr_123 --slot tone=casual --slot length=short
112
153
 
@@ -116,11 +157,19 @@ Examples:
116
157
  exodus workflow export X --version 3 --out v3.yaml && exodus workflow import v3.yaml --update <id>
117
158
 
118
159
  Notes:
160
+ workflow describe prints the ENTRY CONTRACT — every input the workflow asks
161
+ for, whether it's required or optional, the shape it accepts (a dropdown and
162
+ its choices, a number, a true/false toggle; a plain text box shows no tag),
163
+ and the author's help text. Supply those keys with --input; a value the
164
+ contract can't accept is rejected before the run starts, naming the field.
165
+ "workflow run --fill <name>" launches from a saved fill instead of typing the
166
+ inputs again — the fill's values fill the contract, and any --input you pass
167
+ alongside it overrides just that key.
119
168
  Cold-start is a template, not a blank file: "workflow templates" lists the
120
169
  starters (incl. Winner Flywheel), "workflow templates export <key> --out f.yaml"
121
170
  writes the server-rendered YAML verbatim — edit it, then "workflow import f.yaml".
122
171
  "workflow schema" prints the LIVE graph vocabulary (node kinds, ports, config
123
- rules, transform faces, gate policies, wiring rules) from the backend you're
172
+ rules, transform faces, collector policy, wiring rules) from the backend you're
124
173
  deployed against, so what you author matches what will validate; --kind/--face
125
174
  narrow it, --json is the machine payload. "workflow validate <file>" checks a
126
175
  file against the live backend (it IS import --dry-run under its own door).
@@ -141,15 +190,15 @@ Notes:
141
190
  --slug filters are ignored in that mode). workflow bots --slug <slug> --json
142
191
  emits just that one bot's catalog JSON.
143
192
  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).
193
+ (gate/repair/slots/legacy) and how it started (bg / trig:<event>). A run parked
194
+ at a checkpoint is resolved with the "checkpoint" verbs: show what the step
195
+ produced, edit one output in place (edit 1 --text ...), approve (resume),
196
+ retry (re-run just that step), or cancel. A require-all collector that stalled
197
+ on a dead input is a "repair" park — retry it, skip the dead input, or kill the
198
+ run. A nested sub-workflow waiting on inputs is a "slots" park — answer it with
199
+ repeatable --slot key=value flags (run "answer" with no --slot to list the slot
200
+ ids it wants). The "gate" verbs retired in 2.0 along with the Gate node — the
201
+ command now prints a pointer at "checkpoint" and exits 1.
153
202
  `.trim();
154
203
  const LIST_PATH = "/api/v2/workflows";
155
204
  const RUN_PATH = "/api/v2/workflows/run";
@@ -157,6 +206,8 @@ const STATUS_PATH = "/api/v2/workflow";
157
206
  const EXPORT_PATH = "/api/v2/workflows/export";
158
207
  const IMPORT_PATH = "/api/v2/workflows/import";
159
208
  const DESCRIBE_PATH = "/api/v2/workflows/describe";
209
+ const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
210
+ const ASSETS_PATH = "/api/v2/workflows/assets";
160
211
  const CATALOG_PATH = "/api/v2/workflows/catalog";
161
212
  const TRIGGERS_SET_ENABLED_PATH = "/api/v2/workflows/triggers/set-enabled";
162
213
  const TRIGGERS_FIRE_PATH = "/api/v2/workflows/triggers/fire";
@@ -166,15 +217,13 @@ const VERSIONS_PATH = "/api/v2/workflows/versions";
166
217
  const VERSIONS_CAP = 50;
167
218
  const INBOX_PATH = "/api/v2/workflow/inbox";
168
219
  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
220
  const CANCEL_PATH = "/api/v2/workflow/cancel";
173
221
  const ANSWER_PATH = "/api/v2/workflow/answer";
174
222
  const REPAIR_RETRY_PATH = "/api/v2/workflow/repair/retry";
175
223
  const REPAIR_SKIP_PATH = "/api/v2/workflow/repair/skip";
176
- const CHAT_PATH = "/api/sessions/chat";
177
- const CHAT_TIMEOUT_MS = 320_000;
224
+ const CHECKPOINT_RETRY_PATH = "/api/v2/workflow/checkpoint/retry";
225
+ const CHECKPOINT_EDIT_PATH = "/api/v2/workflow/checkpoint/edit";
226
+ const CHECKPOINT_RESOLVE_ITEMS_PATH = "/api/v2/workflow/checkpoint/resolve-items";
178
227
  const RUN_PAGE_PREFIX = "/runs/";
179
228
  const VALUE_FLAGS = new Set([
180
229
  "id",
@@ -184,6 +233,7 @@ const VALUE_FLAGS = new Set([
184
233
  "slug",
185
234
  "update",
186
235
  "terminal",
236
+ "fill",
187
237
  "text",
188
238
  "kind",
189
239
  "face",
@@ -191,7 +241,18 @@ const VALUE_FLAGS = new Set([
191
241
  "file",
192
242
  "reason",
193
243
  "slot",
244
+ "reject",
194
245
  ]);
246
+ function defaultMkdirp(dir) {
247
+ fs.mkdirSync(dir, { recursive: true });
248
+ }
249
+ async function defaultDownloadToFile(url, path) {
250
+ const res = await fetch(url);
251
+ if (!res.ok)
252
+ throw new Error(`download failed with HTTP ${res.status}`);
253
+ const buf = Buffer.from(await res.arrayBuffer());
254
+ fs.writeFileSync(path, buf);
255
+ }
195
256
  const defaultDeps = {
196
257
  get: (path) => apiGet(path),
197
258
  getText: (path) => apiGetText(path),
@@ -199,15 +260,57 @@ const defaultDeps = {
199
260
  readFile: (path) => fs.readFileSync(path, "utf-8"),
200
261
  writeFile: (path, text) => fs.writeFileSync(path, text, "utf-8"),
201
262
  poll: (opts) => pollUntilDone(opts),
263
+ mkdirp: defaultMkdirp,
264
+ downloadToFile: defaultDownloadToFile,
202
265
  postDashboard: (path, body, opts) => apiPostDashboard(path, body, opts),
203
266
  dashboardUrl: getDashboardUrl(),
267
+ statFile: (filePath) => {
268
+ try {
269
+ const stat = fs.statSync(filePath);
270
+ return stat.isFile() ? { size: stat.size } : null;
271
+ }
272
+ catch {
273
+ return null;
274
+ }
275
+ },
276
+ readFileBytes: (filePath) => fs.readFileSync(filePath),
277
+ uploadBytes: async (uploadUrl, contentType, bytes) => {
278
+ const res = await fetch(uploadUrl, {
279
+ method: "POST",
280
+ headers: { "Content-Type": contentType },
281
+ body: new Blob([bytes]),
282
+ });
283
+ if (!res.ok) {
284
+ const body = await res.text().catch(() => "");
285
+ return { ok: false, status: res.status, body };
286
+ }
287
+ const parsed = (await res.json().catch(() => ({})));
288
+ return { ok: true, status: res.status, storageId: parsed.storageId };
289
+ },
204
290
  };
205
291
  function asErrorResult(res, json) {
206
292
  return {
207
293
  code: 1,
208
294
  lines: json
209
295
  ? [JSON.stringify({ ok: false, status: res.status, data: res.data })]
210
- : [formatError(res)],
296
+ : [formatApiError(res)],
297
+ };
298
+ }
299
+ function resolveIdErrorResult(e, json) {
300
+ if (e instanceof WorkflowResolveError) {
301
+ return {
302
+ code: 1,
303
+ lines: json
304
+ ? [JSON.stringify({ ok: false, status: e.status, data: e.data })]
305
+ : [e.message],
306
+ };
307
+ }
308
+ const message = e instanceof Error ? e.message : String(e);
309
+ return {
310
+ code: 1,
311
+ lines: json
312
+ ? [JSON.stringify({ ok: false, status: 0, data: { error: { message } } })]
313
+ : [message],
211
314
  };
212
315
  }
213
316
  function isRecord(value) {
@@ -252,12 +355,29 @@ function outputLines(output) {
252
355
  if (output.type === "primer") {
253
356
  return [` primer:${output.primerKind}: ${truncateText(output.text)}`];
254
357
  }
255
- return [` image: ${output.imageUrl ?? output.storageId}`];
358
+ if (output.type === "image") {
359
+ const url = output.imageUrl ?? output.storageId;
360
+ return url ? [` image: ${url}`] : [];
361
+ }
362
+ return [];
256
363
  }
257
- function progressLine(node) {
364
+ function progressLine(node, parked = false) {
258
365
  const err = node.error ? ` — error: ${node.error}` : "";
366
+ if (parked) {
367
+ return ` ⏸ ${node.nodeId} (${node.kind}) awaiting approval`;
368
+ }
259
369
  return ` ${statusIcon(node.status)} ${node.nodeId} (${node.kind}) ${node.status}${err}`;
260
370
  }
371
+ function gateParkedNodeId(status, pauseReason, pausedNodeId) {
372
+ if (!status || normalizeRunStatus(status) !== "awaiting-approval")
373
+ return undefined;
374
+ if (pauseReason === "taste" || pauseReason === "checkpoint" || pauseReason === undefined) {
375
+ return pausedNodeId;
376
+ }
377
+ return undefined;
378
+ }
379
+ const RETIRED_GATE_VERB_POINTER = "The Gate node retired in 2.0 — runs now pause with the checkpoint switch on a node. " +
380
+ "Use: exodus workflow checkpoint <runId> [approve|edit|retry|cancel]";
261
381
  export function formatPauseNotice(pauseReason, runId, dashboardUrl) {
262
382
  if (!pauseReason) {
263
383
  return [
@@ -267,12 +387,24 @@ export function formatPauseNotice(pauseReason, runId, dashboardUrl) {
267
387
  if (pauseReason === "call") {
268
388
  return [" ⏸ waiting on a child workflow run — it resumes on its own."];
269
389
  }
390
+ if (pauseReason === "checkpoint") {
391
+ return [
392
+ " ⏸ paused at a checkpoint — a step's output is waiting on your approval.",
393
+ ` Resolve here: exodus workflow checkpoint ${runId}`,
394
+ ` Or in the app: ${dashboardUrl}${RUN_PAGE_PREFIX}${runId}`,
395
+ ];
396
+ }
397
+ if (pauseReason === "taste") {
398
+ return [
399
+ " ⏸ paused at a Gate box, which retired in 2.0. Approvals now happen with the checkpoint switch.",
400
+ ` Cancel it here: exodus workflow checkpoint ${runId} cancel`,
401
+ ` Or in the app: ${dashboardUrl}${RUN_PAGE_PREFIX}${runId}`,
402
+ " Then run the workflow again.",
403
+ ];
404
+ }
270
405
  const resolveVerb = pauseReason === "repair"
271
406
  ? `exodus workflow repair ${runId} retry|skip|kill`
272
- : pauseReason === "slots"
273
- ? `exodus workflow answer ${runId} --slot key=value`
274
- :
275
- `exodus workflow gate ${runId}`;
407
+ : `exodus workflow answer ${runId} --slot key=value`;
276
408
  return [
277
409
  " ⏸ paused for review — waiting on you.",
278
410
  ` Resolve here: ${resolveVerb}`,
@@ -300,7 +432,7 @@ function expandInputValue(key, value, readFile) {
300
432
  }
301
433
  return value;
302
434
  }
303
- export function parseInputFlags(args, readFile) {
435
+ export function parseRawInputFlags(args) {
304
436
  const inputs = {};
305
437
  for (let i = 0; i < args.length; i++) {
306
438
  const arg = args[i];
@@ -323,10 +455,259 @@ export function parseInputFlags(args, readFile) {
323
455
  const key = raw.slice(0, eq).trim();
324
456
  if (!key)
325
457
  throw new Error(`--input must include a key (got "${raw}")`);
326
- inputs[key] = expandInputValue(key, raw.slice(eq + 1), readFile);
458
+ inputs[key] = raw.slice(eq + 1);
459
+ }
460
+ return inputs;
461
+ }
462
+ export function parseInputFlags(args, readFile) {
463
+ const inputs = parseRawInputFlags(args);
464
+ for (const [key, value] of Object.entries(inputs)) {
465
+ inputs[key] = expandInputValue(key, value, readFile);
327
466
  }
328
467
  return inputs;
329
468
  }
469
+ const MB = 1024 * 1024;
470
+ export const ASSET_UPLOAD_POLICY = {
471
+ image: {
472
+ mimeByExtension: {
473
+ ".png": "image/png",
474
+ ".jpg": "image/jpeg",
475
+ ".jpeg": "image/jpeg",
476
+ ".webp": "image/webp",
477
+ ".gif": "image/gif",
478
+ },
479
+ maxBytes: 15 * MB,
480
+ accepts: "PNG, JPEG, WebP, GIF",
481
+ },
482
+ video: {
483
+ mimeByExtension: {
484
+ ".mp4": "video/mp4",
485
+ ".m4v": "video/mp4",
486
+ ".mov": "video/quicktime",
487
+ ".webm": "video/webm",
488
+ },
489
+ maxBytes: 200 * MB,
490
+ accepts: "MP4, MOV, WebM",
491
+ },
492
+ audio: {
493
+ mimeByExtension: {
494
+ ".mp3": "audio/mpeg",
495
+ ".m4a": "audio/mp4",
496
+ ".wav": "audio/wav",
497
+ ".ogg": "audio/ogg",
498
+ },
499
+ maxBytes: 50 * MB,
500
+ accepts: "MP3, M4A, WAV, OGG",
501
+ },
502
+ document: {
503
+ mimeByExtension: {
504
+ ".pdf": "application/pdf",
505
+ ".txt": "text/plain",
506
+ ".md": "text/markdown",
507
+ ".doc": "application/msword",
508
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
509
+ },
510
+ maxBytes: 25 * MB,
511
+ accepts: "PDF, TXT, Markdown, DOC, DOCX",
512
+ },
513
+ };
514
+ const MEDIA_FAMILIES = ["image", "video", "audio", "document"];
515
+ function sizeLabel(bytes) {
516
+ return `${(bytes / MB).toFixed(1)}MB`;
517
+ }
518
+ function capLabel(bytes) {
519
+ return `${Math.round(bytes / MB)}MB`;
520
+ }
521
+ function assetMimeFor(filePath, assetType) {
522
+ const ext = path.extname(filePath).toLowerCase();
523
+ if (!ext)
524
+ return null;
525
+ for (const family of assetType ? [assetType] : MEDIA_FAMILIES) {
526
+ const mime = ASSET_UPLOAD_POLICY[family].mimeByExtension[ext];
527
+ if (mime)
528
+ return { mime, family };
529
+ }
530
+ return null;
531
+ }
532
+ function acceptedLabel(assetType) {
533
+ if (assetType) {
534
+ const policy = ASSET_UPLOAD_POLICY[assetType];
535
+ return `${assetType} files (${policy.accepts}, up to ${capLabel(policy.maxBytes)})`;
536
+ }
537
+ return MEDIA_FAMILIES.map((family) => `${family} (${ASSET_UPLOAD_POLICY[family].accepts})`).join(", ");
538
+ }
539
+ function looksLikePath(value) {
540
+ if (value.includes("/") || value.includes("\\"))
541
+ return true;
542
+ if (value.startsWith("~"))
543
+ return true;
544
+ return /\.[A-Za-z0-9]{1,8}$/.test(value);
545
+ }
546
+ function planAssetUpload(field, filePath, size, assetType, deps) {
547
+ const name = path.basename(filePath);
548
+ const picked = assetMimeFor(filePath, assetType);
549
+ if (!picked) {
550
+ throw new Error(`--input ${field}: can't tell what kind of file "${name}" is — ${field} takes ${acceptedLabel(assetType)}`);
551
+ }
552
+ const { maxBytes } = ASSET_UPLOAD_POLICY[picked.family];
553
+ if (size > maxBytes) {
554
+ throw new Error(`--input ${field}: "${name}" is ${sizeLabel(size)} — over the ${capLabel(maxBytes)} limit for ${picked.family} uploads`);
555
+ }
556
+ if (!deps.readFileBytes || !deps.uploadBytes) {
557
+ throw new Error(`--input ${field}: cannot upload files here (no file access)`);
558
+ }
559
+ return { field, filePath, name, size, mime: picked.mime, family: picked.family };
560
+ }
561
+ async function pushAssetFile(plan, deps) {
562
+ const { field, name } = plan;
563
+ const mint = await deps.post(ASSET_UPLOAD_URL_PATH, {});
564
+ if (!mint.ok) {
565
+ throw new Error(missingRouteLine(mint, "workflow file inputs") ?? formatApiError(mint));
566
+ }
567
+ const minted = mint.data;
568
+ if (!minted.uploadUrl) {
569
+ throw new Error(`--input ${field}: the server did not return an upload URL for "${name}"`);
570
+ }
571
+ if (!minted.receiptId) {
572
+ throw new Error(`--input ${field}: the server did not return an upload receipt for "${name}"`);
573
+ }
574
+ let bytes;
575
+ try {
576
+ bytes = deps.readFileBytes(plan.filePath);
577
+ }
578
+ catch (e) {
579
+ const msg = e instanceof Error ? e.message : String(e);
580
+ throw new Error(`--input ${field}: could not read file "${plan.filePath}": ${msg}`);
581
+ }
582
+ const put = await deps.uploadBytes(minted.uploadUrl, plan.mime, bytes);
583
+ if (!put.ok || !put.storageId) {
584
+ const detail = put.body ? `: ${put.body.slice(0, 200)}` : "";
585
+ throw new Error(`--input ${field}: upload of "${name}" failed (HTTP ${put.status})${detail}`);
586
+ }
587
+ const registered = await deps.post(ASSETS_PATH, {
588
+ storageId: put.storageId,
589
+ receiptId: minted.receiptId,
590
+ filename: name,
591
+ });
592
+ if (!registered.ok) {
593
+ throw new Error(missingRouteLine(registered, "workflow file inputs") ?? formatApiError(registered));
594
+ }
595
+ const data = registered.data;
596
+ if (!data.assetId) {
597
+ throw new Error(`--input ${field}: the server did not return an asset id for "${name}"`);
598
+ }
599
+ return { assetId: data.assetId, mediaType: data.mediaType ?? plan.family };
600
+ }
601
+ function formatInputProblems(problems) {
602
+ if (problems.length === 1)
603
+ return problems[0];
604
+ return [
605
+ `${problems.length} of the --input values can't be used:`,
606
+ ...problems.map((line) => ` ${line}`),
607
+ ].join("\n");
608
+ }
609
+ function withReusableAssetIds(message, done) {
610
+ if (done.length === 0)
611
+ return message;
612
+ const flags = done.map((d) => `--input ${d.field}=${d.assetId}`).join(" ");
613
+ return (`${message}\n` +
614
+ `These files did upload — pass their ids to skip re-uploading them: ${flags}`);
615
+ }
616
+ function looksLikeFileArgument(value, deps) {
617
+ if (value.startsWith("@@"))
618
+ return false;
619
+ if (value.startsWith("@"))
620
+ return true;
621
+ if ((deps.statFile?.(value) ?? null) !== null)
622
+ return true;
623
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value))
624
+ return false;
625
+ if (/\s/.test(value))
626
+ return false;
627
+ return looksLikePath(value);
628
+ }
629
+ async function prepareRunInputs(workflowId, raw, deps, note, scoped) {
630
+ const described = await deps.get(`${DESCRIBE_PATH}?id=${encodeURIComponent(workflowId)}`);
631
+ if (!described.ok) {
632
+ const fileish = Object.keys(raw).filter((key) => looksLikeFileArgument(raw[key], deps));
633
+ if (fileish.length > 0) {
634
+ const reason = formatApiError(described).split("\n")[0];
635
+ throw new Error(`--input ${fileish.join(", ")}: couldn't confirm this workflow's inputs ` +
636
+ `(describe failed: ${reason}) — retry, or pass an already-uploaded asset id ` +
637
+ `instead of a file path.`);
638
+ }
639
+ const text = {};
640
+ for (const [key, value] of Object.entries(raw)) {
641
+ text[key] = expandInputValue(key, value, deps.readFile);
642
+ }
643
+ return text;
644
+ }
645
+ const descriptors = described.data.inputs ?? [];
646
+ const assetFields = new Map();
647
+ for (const descriptor of descriptors) {
648
+ if (descriptor?.source === "asset")
649
+ assetFields.set(descriptor.fieldName, descriptor);
650
+ }
651
+ if (!scoped) {
652
+ const missing = [...assetFields.values()].filter((d) => d.required && (raw[d.fieldName] ?? "").trim() === "");
653
+ if (missing.length > 0) {
654
+ const names = missing.map((d) => d.fieldName).join(", ");
655
+ const first = missing[0];
656
+ throw new Error(`Missing required file input(s): ${names}. Pass each one as a local file — ` +
657
+ `e.g. --input ${first.fieldName}=./path/to/file` +
658
+ (first.assetType ? ` (${acceptedLabel(first.assetType)})` : ""));
659
+ }
660
+ }
661
+ const prepared = {};
662
+ const planned = [];
663
+ const problems = [];
664
+ for (const [key, value] of Object.entries(raw)) {
665
+ const descriptor = assetFields.get(key);
666
+ if (!descriptor) {
667
+ try {
668
+ prepared[key] = expandInputValue(key, value, deps.readFile);
669
+ }
670
+ catch (e) {
671
+ problems.push(e instanceof Error ? e.message : String(e));
672
+ }
673
+ continue;
674
+ }
675
+ const hinted = value.startsWith("@");
676
+ const candidate = hinted ? value.slice(1) : value;
677
+ const stat = deps.statFile?.(candidate) ?? null;
678
+ if (!stat) {
679
+ if (hinted || looksLikePath(candidate)) {
680
+ problems.push(`--input ${key}: file not found: ${candidate}`);
681
+ continue;
682
+ }
683
+ prepared[key] = value;
684
+ continue;
685
+ }
686
+ try {
687
+ planned.push(planAssetUpload(key, candidate, stat.size, descriptor.assetType, deps));
688
+ }
689
+ catch (e) {
690
+ problems.push(e instanceof Error ? e.message : String(e));
691
+ }
692
+ }
693
+ if (problems.length > 0)
694
+ throw new Error(formatInputProblems(problems));
695
+ const done = [];
696
+ for (const plan of planned) {
697
+ let uploaded;
698
+ try {
699
+ uploaded = await pushAssetFile(plan, deps);
700
+ }
701
+ catch (e) {
702
+ const message = e instanceof Error ? e.message : String(e);
703
+ throw new Error(withReusableAssetIds(message, done));
704
+ }
705
+ note(` uploaded ${plan.name} → ${plan.field} (${uploaded.mediaType}, ${sizeLabel(plan.size)})`);
706
+ prepared[plan.field] = uploaded.assetId;
707
+ done.push({ field: plan.field, assetId: uploaded.assetId });
708
+ }
709
+ return prepared;
710
+ }
330
711
  export function parseTerminalFlags(args) {
331
712
  const ids = [];
332
713
  for (let i = 0; i < args.length; i++) {
@@ -351,6 +732,31 @@ export function parseTerminalFlags(args) {
351
732
  }
352
733
  return ids;
353
734
  }
735
+ export function parseFillFlag(args) {
736
+ let name;
737
+ for (let i = 0; i < args.length; i++) {
738
+ const arg = args[i];
739
+ let raw;
740
+ if (arg === "--fill") {
741
+ raw = args[i + 1];
742
+ i++;
743
+ }
744
+ else if (arg.startsWith("--fill=")) {
745
+ raw = arg.slice("--fill=".length);
746
+ }
747
+ else {
748
+ continue;
749
+ }
750
+ if (raw === undefined || raw.startsWith("--")) {
751
+ throw new Error("--fill requires a saved fill's name");
752
+ }
753
+ const trimmed = raw.trim();
754
+ if (!trimmed)
755
+ throw new Error("--fill requires a saved fill's name");
756
+ name = trimmed;
757
+ }
758
+ return name;
759
+ }
354
760
  export function formatWorkflowList(workflows) {
355
761
  if (workflows.length === 0)
356
762
  return "No workflows found for the active brand.";
@@ -365,7 +771,7 @@ export function formatWorkflowList(workflows) {
365
771
  export function formatRecentRuns(runs) {
366
772
  if (runs.length === 0)
367
773
  return "No workflow runs found for the active brand.";
368
- return table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, r.status, dateOnly(r.createdAt), r._id]));
774
+ return table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, runStatusLabel(r.status), dateOnly(r.createdAt), r._id]));
369
775
  }
370
776
  export function formatWorkflowVersions(versions) {
371
777
  if (versions.length === 0) {
@@ -398,6 +804,28 @@ export function formatImportSummary(result, mode = {}) {
398
804
  lines.push(`workflowId: ${result.workflowId}`);
399
805
  lines.push(`nodes: ${result.nodeCount}`);
400
806
  lines.push(`edges: ${result.edgeCount}`);
807
+ if (result.triggers && result.triggers.length > 0) {
808
+ lines.push("");
809
+ const enabledCount = result.triggers.filter((t) => t.enabled).length;
810
+ lines.push(`Triggers (${result.triggers.length}):`);
811
+ for (const t of result.triggers) {
812
+ const detail = t.type === "event" ? `event (${t.event})` : `cron (${t.cron})`;
813
+ if (t.enabled) {
814
+ const fires = t.type === "event"
815
+ ? t.event === "winner-promoted"
816
+ ? "fires on every promote"
817
+ : `fires on ${t.event}`
818
+ : `fires on schedule ${t.cron}`;
819
+ lines.push(` ⚠ ${detail} — ENABLED, ${fires}`);
820
+ }
821
+ else {
822
+ lines.push(` ${detail} — disabled`);
823
+ }
824
+ }
825
+ if (enabledCount > 0) {
826
+ 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>`);
827
+ }
828
+ }
401
829
  if (result.unresolved.length > 0) {
402
830
  lines.push("");
403
831
  lines.push(`Unresolved references (${result.unresolved.length}):`);
@@ -421,7 +849,7 @@ export function formatWorkflowRun(run) {
421
849
  lines.push(`workflowId: ${run.workflowId}`);
422
850
  if (run.triggerRunId)
423
851
  lines.push(`triggerRunId: ${run.triggerRunId}`);
424
- lines.push(`verdict: ${run.status}${counts ? ` (${counts})` : ""}`);
852
+ lines.push(`verdict: ${runStatusLabel(run.status)}${counts ? ` (${counts})` : ""}`);
425
853
  if (run.isTerminal)
426
854
  lines.push("terminal: yes");
427
855
  if (run.error)
@@ -430,20 +858,28 @@ export function formatWorkflowRun(run) {
430
858
  const inputs = Object.entries(run.inputs).map(([k, v]) => `${k}=${v}`).join(", ");
431
859
  lines.push(`inputs: ${inputs}`);
432
860
  }
861
+ const parkedNodeId = gateParkedNodeId(run.status, run.pauseReason, run.pausedNodeId);
433
862
  if (run.nodes.length > 0) {
434
863
  lines.push("");
435
864
  lines.push(`Nodes (${run.nodes.length}):`);
436
865
  for (const node of run.nodes) {
437
- lines.push(progressLine(node));
866
+ lines.push(progressLine(node, parkedNodeId !== undefined && node.nodeId === parkedNodeId));
438
867
  for (const output of node.outputs)
439
868
  lines.push(...outputLines(output));
440
869
  }
441
870
  }
871
+ if (run.deliveries && run.deliveries.length > 0) {
872
+ lines.push("");
873
+ lines.push(`Deliveries (${run.deliveries.length}):`);
874
+ for (const delivery of run.deliveries)
875
+ lines.push(...deliveryLines(delivery));
876
+ }
442
877
  if (run.outputs && run.outputs.length > 0) {
443
878
  lines.push("");
444
879
  lines.push(`Outputs (${run.outputs.length}):`);
445
- for (const output of run.outputs)
446
- lines.push(...runOutputLines(output));
880
+ for (const output of run.outputs) {
881
+ lines.push(...runOutputLines(output, parkedNodeId !== undefined && output.nodeId === parkedNodeId));
882
+ }
447
883
  }
448
884
  if (run.sessions && run.sessions.length > 0) {
449
885
  lines.push("");
@@ -454,30 +890,171 @@ export function formatWorkflowRun(run) {
454
890
  }
455
891
  return lines.join("\n");
456
892
  }
457
- function runOutputLines(output) {
893
+ function runOutputLines(output, awaitingReview = false) {
458
894
  const slug = output.botSlug ? ` (${output.botSlug})` : "";
895
+ const review = awaitingReview ? " — awaiting approval, not yet approved" : "";
459
896
  if (output.type === "image") {
460
- return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}`];
897
+ return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}${review}`];
461
898
  }
462
899
  if (output.type === "video") {
463
900
  const tag = output.final === true ? "final video" : "video";
464
- return [` ${output.label} [${tag}]${slug}: ${output.videoUrl ?? "(no url)"}`];
901
+ return [` ${output.label} [${tag}]${slug}: ${output.videoUrl ?? "(no url)"}${review}`];
465
902
  }
466
903
  if (output.type === "audio") {
467
- return [` ${output.label} [audio]${slug}: ${output.audioUrl ?? "(no url)"}`];
904
+ return [` ${output.label} [audio]${slug}: ${output.audioUrl ?? "(no url)"}${review}`];
905
+ }
906
+ if (output.type === "document") {
907
+ const name = output.filename ? ` ${output.filename}` : "";
908
+ return [
909
+ ` ${output.label} [document]${slug}:${name} ${output.documentUrl ?? "(no url)"}${review}`,
910
+ ];
468
911
  }
469
912
  if (output.type === "frames") {
470
913
  const n = output.frames?.length ?? 0;
471
- return [` ${output.label} [frames]${slug}: ${n} scene${n === 1 ? "" : "s"}`];
914
+ return [` ${output.label} [frames]${slug}: ${n} scene${n === 1 ? "" : "s"}${review}`];
472
915
  }
473
916
  if (output.type === "storyboard") {
474
- return [` ${output.label} [storyboard]${slug}: use --json for the scene plan`];
917
+ return [` ${output.label} [storyboard]${slug}: use --json for the scene plan${review}`];
475
918
  }
476
919
  const raw = output.text ?? "";
477
920
  const normalized = raw.replace(/\s+/g, " ").trim();
478
921
  const body = truncateText(raw, 400);
479
922
  const note = normalized.length > 400 ? "\n (truncated — use --json for the full text)" : "";
480
- return [` ${output.label} [text]${slug}:`, ` ${body}${note}`];
923
+ return [` ${output.label} [text]${slug}${review}:`, ` ${body}${note}`];
924
+ }
925
+ function deliveryLines(delivery) {
926
+ const status = delivery.status === "unfulfilled" && delivery.error
927
+ ? `unfulfilled — ${delivery.error}`
928
+ : delivery.status;
929
+ const lines = [` ${delivery.label} (${delivery.key}) · ${delivery.type} · ${status}`];
930
+ for (const artifact of delivery.artifacts) {
931
+ for (const line of runOutputLines(artifact))
932
+ lines.push(` ${line}`);
933
+ }
934
+ return lines;
935
+ }
936
+ const ASSET_FALLBACK_EXT = {
937
+ image: "png",
938
+ video: "mp4",
939
+ audio: "mp3",
940
+ };
941
+ export function workflowFilenameSlug(name) {
942
+ const slug = (name ?? "")
943
+ .toLowerCase()
944
+ .replace(/[^a-z0-9]+/g, "-")
945
+ .replace(/^-+|-+$/g, "");
946
+ return slug || "workflow";
947
+ }
948
+ function extensionFromUrl(url) {
949
+ const withoutQuery = url.split(/[?#]/)[0];
950
+ const m = withoutQuery.match(/\.([a-z0-9]{1,5})$/i);
951
+ return m ? m[1].toLowerCase() : undefined;
952
+ }
953
+ function planArtifactFile(artifact) {
954
+ if (artifact.type === "text") {
955
+ return { kind: "text", ext: "md", body: artifact.text ?? "" };
956
+ }
957
+ if (artifact.type === "storyboard") {
958
+ const json = artifact.storyboardJson;
959
+ if (!json)
960
+ return { kind: "none", reason: "no storyboard JSON on this artifact" };
961
+ return { kind: "text", ext: "json", body: json.endsWith("\n") ? json : `${json}\n` };
962
+ }
963
+ if (artifact.type === "frames") {
964
+ return {
965
+ kind: "text",
966
+ ext: "json",
967
+ body: `${JSON.stringify(artifact.frames ?? [], null, 2)}\n`,
968
+ };
969
+ }
970
+ const url = artifact.type === "image"
971
+ ? (artifact.imageUrl ?? undefined)
972
+ : artifact.type === "video"
973
+ ? artifact.videoUrl
974
+ : artifact.type === "document"
975
+ ?
976
+ artifact.documentUrl
977
+ : artifact.audioUrl;
978
+ if (!url)
979
+ return { kind: "none", reason: "no downloadable URL on this artifact" };
980
+ const filenameExt = artifact.type === "document" && artifact.filename
981
+ ? extensionFromUrl(artifact.filename)
982
+ : undefined;
983
+ return {
984
+ kind: "download",
985
+ ext: filenameExt ??
986
+ extensionFromUrl(url) ??
987
+ ASSET_FALLBACK_EXT[artifact.type] ??
988
+ "bin",
989
+ url,
990
+ };
991
+ }
992
+ export async function saveDeliveries(run, dir, deps) {
993
+ const paths = [];
994
+ if (!run.isTerminal) {
995
+ return {
996
+ paths,
997
+ lines: [
998
+ `Nothing saved — this run isn't finished yet (${run.status}). Try again once it is: exodus workflow status --id ${run._id} --out ${dir}`,
999
+ ],
1000
+ };
1001
+ }
1002
+ const deliveries = run.deliveries ?? [];
1003
+ if (deliveries.length === 0) {
1004
+ return {
1005
+ paths,
1006
+ lines: [
1007
+ "Nothing saved — this run has no named delivery slots. Either the workflow has no Output nodes, or the backend it ran on predates named outputs.",
1008
+ ],
1009
+ };
1010
+ }
1011
+ const mkdirp = deps.mkdirp ?? defaultMkdirp;
1012
+ const downloadToFile = deps.downloadToFile ?? defaultDownloadToFile;
1013
+ mkdirp(dir);
1014
+ const stem = `${workflowFilenameSlug(run.workflowName)}-${run._id.slice(-8)}`;
1015
+ const lines = [`Saved deliveries to ${dir}:`];
1016
+ for (const delivery of deliveries) {
1017
+ if (delivery.status !== "delivered" || delivery.artifacts.length === 0) {
1018
+ const why = delivery.error !== undefined
1019
+ ? `unfulfilled: ${delivery.error}`
1020
+ : delivery.status === "delivered"
1021
+ ? "delivered but empty"
1022
+ : "unfulfilled";
1023
+ lines.push(` skipped ${delivery.label} (${delivery.key}) — ${why}`);
1024
+ continue;
1025
+ }
1026
+ for (let i = 0; i < delivery.artifacts.length; i++) {
1027
+ const plan = planArtifactFile(delivery.artifacts[i]);
1028
+ const suffix = i === 0 ? "" : `-${i + 1}`;
1029
+ if (plan.kind === "none") {
1030
+ lines.push(` skipped ${delivery.label} (${delivery.key})${suffix} — ${plan.reason}`);
1031
+ continue;
1032
+ }
1033
+ const file = nodePath.join(dir, `${stem}-${workflowFilenameSlug(delivery.key)}${suffix}.${plan.ext}`);
1034
+ try {
1035
+ if (plan.kind === "text")
1036
+ deps.writeFile(file, plan.body);
1037
+ else
1038
+ await downloadToFile(plan.url, file);
1039
+ lines.push(` wrote ${file}`);
1040
+ paths.push(file);
1041
+ }
1042
+ catch (e) {
1043
+ lines.push(` failed ${file} — ${e instanceof Error ? e.message : String(e)}`);
1044
+ }
1045
+ }
1046
+ }
1047
+ lines.push(`${paths.length} file${paths.length === 1 ? "" : "s"} written.`);
1048
+ return { lines, paths };
1049
+ }
1050
+ function inputValueHint(input) {
1051
+ if (input.type === "select") {
1052
+ const options = input.options ?? [];
1053
+ return options.length > 0 ? `one of: ${options.join(", ")}` : undefined;
1054
+ }
1055
+ if (input.type === "toggle")
1056
+ return "true or false";
1057
+ return undefined;
481
1058
  }
482
1059
  export function formatDescribe(res) {
483
1060
  const lines = [];
@@ -494,8 +1071,13 @@ export function formatDescribe(res) {
494
1071
  else {
495
1072
  for (const input of res.inputs) {
496
1073
  const req = input.required ? "required" : "optional";
1074
+ const type = input.type && input.type !== "text" ? ` (${input.type})` : "";
497
1075
  const bundle = input.bundleSize !== undefined ? `, bundle=${input.bundleSize}` : "";
498
- lines.push(` ${input.fieldName} — ${input.source}, ${req}${bundle}`);
1076
+ const family = input.source === "asset" && input.assetType ? ` (${input.assetType})` : "";
1077
+ lines.push(` ${input.fieldName} — ${input.source}${family}${type}, ${req}${bundle}`);
1078
+ const hint = inputValueHint(input);
1079
+ if (hint)
1080
+ lines.push(` ${hint}`);
499
1081
  if (input.description)
500
1082
  lines.push(` ${input.description}`);
501
1083
  }
@@ -665,9 +1247,12 @@ function schemaValueLines(value, indent) {
665
1247
  if (isRecord(el)) {
666
1248
  const label = schemaEntryLabel(el);
667
1249
  if (label) {
668
- lines.push(`${indent}- ${label.value}`);
1250
+ const parked = el["parked"] === true;
1251
+ lines.push(`${indent}- ${label.value}${parked ? " (parked)" : ""}`);
669
1252
  const rest = { ...el };
670
1253
  delete rest[label.key];
1254
+ if (parked)
1255
+ delete rest["parked"];
671
1256
  lines.push(...schemaValueLines(rest, `${indent} `));
672
1257
  }
673
1258
  else {
@@ -704,7 +1289,6 @@ const SCHEMA_SECTIONS = [
704
1289
  { title: "Port types", keys: ["portTypes"] },
705
1290
  { title: "Node kinds", keys: ["nodeKinds"] },
706
1291
  { title: "Transform faces", keys: ["transformFaces"] },
707
- { title: "Gate policies", keys: ["gatePolicies"] },
708
1292
  { title: "Collector policy", keys: ["collectorPolicy"] },
709
1293
  { title: "Deposits", keys: ["deposits"] },
710
1294
  { title: "Slots", keys: ["slots"] },
@@ -812,15 +1396,31 @@ function formatImportError(res) {
812
1396
  lines.push(`fix: ${remedy}`);
813
1397
  return lines.join("\n");
814
1398
  }
815
- return formatError(res);
1399
+ return formatApiError(res);
1400
+ }
1401
+ class WorkflowResolveError extends Error {
1402
+ status;
1403
+ data;
1404
+ constructor(status, data, message) {
1405
+ super(message);
1406
+ this.status = status;
1407
+ this.data = data;
1408
+ this.name = "WorkflowResolveError";
1409
+ }
816
1410
  }
817
1411
  export async function resolveWorkflowId(ref, deps) {
818
1412
  const res = await deps.get(LIST_PATH);
819
1413
  if (!res.ok)
820
- throw new Error(formatError(res));
1414
+ throw new WorkflowResolveError(res.status, res.data, formatApiError(res));
821
1415
  const workflows = res.data.workflows ?? [];
822
- const match = workflows.find((w) => w.name.toLowerCase() === ref.toLowerCase());
823
- return match?._id ?? ref;
1416
+ const byName = workflows.find((w) => w.name.toLowerCase() === ref.toLowerCase());
1417
+ if (byName)
1418
+ return byName._id;
1419
+ const byId = workflows.find((w) => w._id === ref);
1420
+ if (byId)
1421
+ return byId._id;
1422
+ const message = `No workflow named "${ref}" on this brand — run: exodus workflow list`;
1423
+ throw new WorkflowResolveError(404, { error: { code: "NOT_FOUND", message } }, message);
824
1424
  }
825
1425
  export async function listFlow(json, deps) {
826
1426
  const res = await deps.get(LIST_PATH);
@@ -838,7 +1438,7 @@ export async function describeFlow(workflowRef, opts, deps) {
838
1438
  workflowId = await resolveWorkflowId(workflowRef, deps);
839
1439
  }
840
1440
  catch (e) {
841
- return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1441
+ return resolveIdErrorResult(e, opts.json ?? false);
842
1442
  }
843
1443
  const res = await deps.get(`${DESCRIBE_PATH}?id=${encodeURIComponent(workflowId)}`);
844
1444
  if (!res.ok)
@@ -872,11 +1472,35 @@ export async function runFlow(workflowRef, opts, deps) {
872
1472
  workflowId = await resolveWorkflowId(workflowRef, deps);
873
1473
  }
874
1474
  catch (e) {
875
- return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1475
+ return resolveIdErrorResult(e, opts.json ?? false);
1476
+ }
1477
+ const preface = [];
1478
+ const scoped = (opts.terminalNodeIds?.length ?? 0) > 0;
1479
+ let inputs;
1480
+ try {
1481
+ inputs = await prepareRunInputs(workflowId, opts.inputs, deps, (line) => {
1482
+ if (opts.json)
1483
+ return;
1484
+ if (opts.onProgressLine)
1485
+ opts.onProgressLine(line);
1486
+ else
1487
+ preface.push(line);
1488
+ }, scoped);
1489
+ }
1490
+ catch (e) {
1491
+ const message = e instanceof Error ? e.message : String(e);
1492
+ return {
1493
+ code: 1,
1494
+ lines: opts.json
1495
+ ?
1496
+ [JSON.stringify({ ok: false, status: 400, data: { error: { code: "BAD_REQUEST", message } } })]
1497
+ : [...preface, message],
1498
+ };
876
1499
  }
877
1500
  const body = {
878
1501
  workflowId,
879
- ...(Object.keys(opts.inputs).length > 0 ? { inputs: opts.inputs } : {}),
1502
+ ...(Object.keys(inputs).length > 0 ? { inputs } : {}),
1503
+ ...(opts.fill && opts.fill.trim() !== "" ? { fill: opts.fill.trim() } : {}),
880
1504
  ...(opts.terminalNodeIds && opts.terminalNodeIds.length > 0
881
1505
  ? { terminalNodeIds: opts.terminalNodeIds }
882
1506
  : {}),
@@ -891,35 +1515,54 @@ export async function runFlow(workflowRef, opts, deps) {
891
1515
  const lines = opts.json
892
1516
  ? []
893
1517
  : [
1518
+ ...preface,
894
1519
  "Workflow run started.",
895
1520
  `runId: ${data.runId}`,
896
1521
  `triggerRunId: ${data.triggerRunId}`,
897
1522
  `Poll: exodus workflow status --id ${data.runId}`,
898
1523
  ];
899
- if (!opts.wait)
1524
+ if (!opts.wait) {
1525
+ if (opts.out !== undefined && !opts.json) {
1526
+ lines.push(`Nothing saved to ${opts.out} yet — the run is still going. Add --wait, or save later: exodus workflow status --id ${data.runId} --out ${opts.out}`);
1527
+ }
900
1528
  return { code: 0, lines };
1529
+ }
901
1530
  if (!opts.json && opts.onProgressLine) {
902
1531
  for (const line of lines)
903
1532
  opts.onProgressLine(line);
904
1533
  lines.length = 0;
905
1534
  }
906
- const waited = await waitForRun(data.runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base }, deps);
1535
+ const waited = await waitForRun(data.runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base, out: opts.out }, deps);
907
1536
  if (opts.json)
908
1537
  return waited;
909
1538
  return { code: waited.code, lines: [...lines, ...waited.lines] };
910
1539
  }
1540
+ const WAIT_TERMINAL_STATUSES = TERMINAL_RUN_STATUSES.flatMap((s) => storedWorkflowStatusForms(s));
911
1541
  async function waitForRun(runId, opts, deps) {
912
1542
  const seen = new Map();
913
1543
  let pausedNotified = false;
1544
+ const landOnPark = opts.landOnPark;
914
1545
  const pollResult = await deps.poll({
915
1546
  path: `${STATUS_PATH}?runId=${encodeURIComponent(runId)}`,
916
1547
  intervalMs: 3_000,
917
1548
  timeoutMs: 60 * 60 * 1000,
918
- terminalStatuses: ["completed", "partial", "failed", "canceled"],
1549
+ terminalStatuses: landOnPark
1550
+ ? [...WAIT_TERMINAL_STATUSES, ...storedWorkflowStatusForms("awaiting-approval")]
1551
+ : WAIT_TERMINAL_STATUSES,
1552
+ ...(landOnPark
1553
+ ? {
1554
+ isDone: (raw) => !(typeof raw["status"] === "string" &&
1555
+ normalizeRunStatus(raw["status"]) === "awaiting-approval") || raw["pauseReason"] === landOnPark.pauseReason,
1556
+ }
1557
+ : {}),
919
1558
  onProgress: (raw) => {
920
1559
  if (opts.json || !opts.onProgressLine)
921
1560
  return;
922
- if (raw["status"] === "awaiting-review" && !pausedNotified) {
1561
+ const rawStatus = raw["status"];
1562
+ const parked = typeof rawStatus === "string" &&
1563
+ normalizeRunStatus(rawStatus) === "awaiting-approval";
1564
+ const isLanding = landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason;
1565
+ if (parked && !pausedNotified && !isLanding) {
923
1566
  pausedNotified = true;
924
1567
  const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
925
1568
  const pauseReason = raw["pauseReason"];
@@ -927,20 +1570,36 @@ async function waitForRun(runId, opts, deps) {
927
1570
  opts.onProgressLine(line);
928
1571
  }
929
1572
  }
1573
+ const parkedNodeId = gateParkedNodeId(raw["status"], raw["pauseReason"], raw["pausedNodeId"]);
930
1574
  const nodes = Array.isArray(raw["nodes"]) ? raw["nodes"] : [];
931
1575
  for (const node of nodes) {
932
- if (seen.get(node.nodeId) === node.status)
1576
+ const isParked = parkedNodeId !== undefined && node.nodeId === parkedNodeId;
1577
+ const renderKey = `${node.status}:${isParked}`;
1578
+ if (seen.get(node.nodeId) === renderKey)
933
1579
  continue;
934
- seen.set(node.nodeId, node.status);
935
- opts.onProgressLine(progressLine(node));
1580
+ seen.set(node.nodeId, renderKey);
1581
+ opts.onProgressLine(progressLine(node, isParked));
936
1582
  }
937
1583
  },
938
1584
  });
1585
+ const terminalRun = !pollResult.timedOut &&
1586
+ isRecord(pollResult.data) &&
1587
+ typeof pollResult.data["_id"] === "string"
1588
+ ? pollResult.data
1589
+ : undefined;
1590
+ const saved = opts.out !== undefined && terminalRun
1591
+ ? await saveDeliveries(terminalRun, opts.out, deps)
1592
+ : undefined;
939
1593
  if (opts.json) {
940
1594
  return {
941
1595
  code: pollResult.ok ? 0 : 1,
942
1596
  lines: [
943
- JSON.stringify({ ...opts.jsonBase, result: pollResult.data, timedOut: pollResult.timedOut }),
1597
+ JSON.stringify({
1598
+ ...opts.jsonBase,
1599
+ result: pollResult.data,
1600
+ timedOut: pollResult.timedOut,
1601
+ ...(saved ? { saved: saved.paths } : {}),
1602
+ }),
944
1603
  ],
945
1604
  };
946
1605
  }
@@ -951,23 +1610,50 @@ async function waitForRun(runId, opts, deps) {
951
1610
  };
952
1611
  }
953
1612
  const lines = [""];
954
- if (isRecord(pollResult.data) && typeof pollResult.data["_id"] === "string") {
955
- lines.push(formatWorkflowRun(pollResult.data));
1613
+ if (terminalRun) {
1614
+ lines.push(formatWorkflowRun(terminalRun));
956
1615
  }
957
1616
  else {
958
1617
  lines.push(`Polling failed: ${JSON.stringify(pollResult.data)}`);
959
1618
  }
1619
+ if (saved)
1620
+ lines.push("", ...saved.lines);
1621
+ if (landOnPark &&
1622
+ isRecord(pollResult.data) &&
1623
+ typeof pollResult.data["status"] === "string" &&
1624
+ normalizeRunStatus(pollResult.data["status"]) === "awaiting-approval" &&
1625
+ pollResult.data["pauseReason"] === landOnPark.pauseReason) {
1626
+ const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
1627
+ lines.push("", landOnPark.headline, ...formatPauseNotice(landOnPark.pauseReason, runId, dashboardUrl).slice(1));
1628
+ }
960
1629
  return { code: pollResult.ok ? 0 : 1, lines };
961
1630
  }
962
1631
  export async function statusFlow(opts, deps) {
1632
+ if (opts.out !== undefined && !opts.id) {
1633
+ return {
1634
+ code: 1,
1635
+ lines: [
1636
+ "--out saves ONE run's outputs, so it needs the run: exodus workflow status --id <runId> --out <dir>",
1637
+ ],
1638
+ };
1639
+ }
963
1640
  const path = opts.id ? `${STATUS_PATH}?runId=${encodeURIComponent(opts.id)}` : STATUS_PATH;
964
1641
  const res = await deps.get(path);
965
1642
  if (!res.ok)
966
1643
  return asErrorResult(res, opts.json);
967
- if (opts.json)
968
- return { code: 0, lines: [JSON.stringify(res.data)] };
969
- if (opts.id)
970
- return { code: 0, lines: [formatWorkflowRun(res.data)] };
1644
+ const saved = opts.out !== undefined && opts.id
1645
+ ? await saveDeliveries(res.data, opts.out, deps)
1646
+ : undefined;
1647
+ if (opts.json) {
1648
+ const payload = saved ? { ...res.data, saved: saved.paths } : res.data;
1649
+ return { code: 0, lines: [JSON.stringify(payload)] };
1650
+ }
1651
+ if (opts.id) {
1652
+ const lines = [formatWorkflowRun(res.data)];
1653
+ if (saved)
1654
+ lines.push("", ...saved.lines);
1655
+ return { code: 0, lines };
1656
+ }
971
1657
  const runs = (res.data.runs ?? []);
972
1658
  return { code: 0, lines: [formatRecentRuns(runs)] };
973
1659
  }
@@ -977,7 +1663,7 @@ export async function exportFlow(workflowRef, opts, deps) {
977
1663
  workflowId = await resolveWorkflowId(workflowRef, deps);
978
1664
  }
979
1665
  catch (e) {
980
- return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1666
+ return resolveIdErrorResult(e, opts.json ?? false);
981
1667
  }
982
1668
  const versionParam = opts.version !== undefined ? `&version=${encodeURIComponent(String(opts.version))}` : "";
983
1669
  const res = await deps.get(`${EXPORT_PATH}?id=${encodeURIComponent(workflowId)}${versionParam}`);
@@ -1011,7 +1697,7 @@ export async function versionsFlow(workflowRef, opts, deps, channel = getChannel
1011
1697
  workflowId = await resolveWorkflowId(workflowRef, deps);
1012
1698
  }
1013
1699
  catch (e) {
1014
- return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1700
+ return resolveIdErrorResult(e, opts.json ?? false);
1015
1701
  }
1016
1702
  const res = await deps.get(`${VERSIONS_PATH}?id=${encodeURIComponent(workflowId)}`);
1017
1703
  const unsupported = missingRouteLine(res, "workflow versions", channel);
@@ -1148,7 +1834,7 @@ export async function triggersListFlow(workflowRef, opts, deps) {
1148
1834
  workflowId = await resolveWorkflowId(workflowRef, deps);
1149
1835
  }
1150
1836
  catch (e) {
1151
- return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1837
+ return resolveIdErrorResult(e, opts.json ?? false);
1152
1838
  }
1153
1839
  const fetched = await fetchTriggers(workflowId, deps);
1154
1840
  if (!fetched.ok)
@@ -1177,7 +1863,7 @@ export async function triggersSetEnabledFlow(workflowRef, n, enabled, opts, deps
1177
1863
  workflowId = await resolveWorkflowId(workflowRef, deps);
1178
1864
  }
1179
1865
  catch (e) {
1180
- return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1866
+ return resolveIdErrorResult(e, opts.json ?? false);
1181
1867
  }
1182
1868
  const fetched = await fetchTriggers(workflowId, deps);
1183
1869
  if (!fetched.ok)
@@ -1206,7 +1892,7 @@ export async function triggersFireFlow(workflowRef, opts, deps) {
1206
1892
  workflowId = await resolveWorkflowId(workflowRef, deps);
1207
1893
  }
1208
1894
  catch (e) {
1209
- return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
1895
+ return resolveIdErrorResult(e, opts.json ?? false);
1210
1896
  }
1211
1897
  const fetched = await fetchTriggers(workflowId, deps);
1212
1898
  if (!fetched.ok)
@@ -1335,9 +2021,9 @@ function formatTextError(res) {
1335
2021
  }
1336
2022
  catch {
1337
2023
  const snippet = body.replace(/\s+/g, " ").trim().slice(0, 300);
1338
- return formatError({ ok: false, status: res.status, data: snippet || "(empty response)" });
2024
+ return formatApiError({ ok: false, status: res.status, data: snippet || "(empty response)" });
1339
2025
  }
1340
- return formatError({ ok: false, status: res.status, data: parsed });
2026
+ return formatApiError({ ok: false, status: res.status, data: parsed });
1341
2027
  }
1342
2028
  export async function schemaFlow(opts, deps) {
1343
2029
  const res = await deps.get(SCHEMA_PATH);
@@ -1358,9 +2044,6 @@ export async function schemaFlow(opts, deps) {
1358
2044
  }
1359
2045
  return { code: 0, lines: [formatSchema(payload)] };
1360
2046
  }
1361
- function shortId(id) {
1362
- return id.length <= 12 ? id : `${id.slice(0, 11)}…`;
1363
- }
1364
2047
  export function formatAge(value, now = Date.now()) {
1365
2048
  if (value === undefined || value === null)
1366
2049
  return "-";
@@ -1385,6 +2068,8 @@ export function parkBadge(pauseReason) {
1385
2068
  return "repair";
1386
2069
  if (pauseReason === "slots")
1387
2070
  return "slots";
2071
+ if (pauseReason === "checkpoint")
2072
+ return "checkpoint";
1388
2073
  return "legacy";
1389
2074
  }
1390
2075
  export function invocationBadge(row) {
@@ -1399,7 +2084,7 @@ export function formatInbox(rows, now = Date.now()) {
1399
2084
  if (rows.length === 0)
1400
2085
  return NO_INBOX;
1401
2086
  return table(["run", "workflow", "kind", "node", "via", "age"], rows.map((r) => [
1402
- shortId(r._id),
2087
+ r._id,
1403
2088
  r.workflowName || "(unnamed)",
1404
2089
  parkBadge(r.pauseReason),
1405
2090
  r.pausedNodeId ?? "-",
@@ -1428,44 +2113,36 @@ function errLine(message, json, status) {
1428
2113
  function okLine(message, payload, json) {
1429
2114
  return { code: 0, lines: [json ? JSON.stringify(payload) : message] };
1430
2115
  }
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
2116
  function asWorkflowRun(data) {
1444
2117
  return isRecord(data) && typeof data["_id"] === "string"
1445
2118
  ? data
1446
2119
  : undefined;
1447
2120
  }
1448
2121
  function describePark(run) {
1449
- if (run.status !== "awaiting-review")
1450
- return `status: ${run.status}`;
2122
+ if (normalizeRunStatus(run.status) !== "awaiting-approval") {
2123
+ return `status: ${runStatusLabel(run.status)}`;
2124
+ }
1451
2125
  switch (run.pauseReason) {
1452
2126
  case "taste":
1453
- return "parked at a gate (taste review)";
2127
+ return "parked at a Gate box, which retired in 2.0 (cancel and re-run)";
1454
2128
  case "repair":
1455
2129
  return "parked for repair";
1456
2130
  case "slots":
1457
2131
  return "parked for slot answers";
1458
2132
  case "call":
1459
2133
  return "parked on a child workflow";
2134
+ case "checkpoint":
2135
+ return `parked at a checkpoint (use: exodus workflow checkpoint ${run._id})`;
1460
2136
  default:
1461
- return "parked at the cost gate (legacy — no text gate)";
2137
+ return "parked at the cost gate (legacy)";
1462
2138
  }
1463
2139
  }
1464
2140
  const PARK_LABEL = {
1465
- taste: "a gate review",
2141
+ taste: "a retired Gate review",
1466
2142
  repair: "a repair",
1467
2143
  slots: "slot answers",
1468
2144
  call: "a child workflow",
2145
+ checkpoint: "a checkpoint approval",
1469
2146
  };
1470
2147
  async function preflightPark(runId, expected, verb, json, deps) {
1471
2148
  const res = await deps.get(`${STATUS_PATH}?runId=${encodeURIComponent(runId)}`);
@@ -1475,44 +2152,89 @@ async function preflightPark(runId, expected, verb, json, deps) {
1475
2152
  if (!run) {
1476
2153
  return { ok: false, result: errLine(`Could not read run ${runId}.`, json) };
1477
2154
  }
1478
- if (run.status !== "awaiting-review" || run.pauseReason !== expected) {
2155
+ const allowed = Array.isArray(expected)
2156
+ ? expected
2157
+ : [expected];
2158
+ if (normalizeRunStatus(run.status) !== "awaiting-approval" ||
2159
+ run.pauseReason === undefined ||
2160
+ !allowed.includes(run.pauseReason)) {
1479
2161
  return {
1480
2162
  ok: false,
1481
- result: errLine(`Run ${runId} is not parked for ${PARK_LABEL[expected]} — it is ${describePark(run)}.`, json),
2163
+ result: errLine(`Run ${runId} is not parked for ${PARK_LABEL[allowed[0]]} — it is ${describePark(run)}.`, json),
1482
2164
  };
1483
2165
  }
1484
2166
  return { ok: true, run };
1485
2167
  }
1486
- function gateCandidates(run) {
2168
+ function checkpointOutputs(run) {
1487
2169
  const node = (run.nodes ?? []).find((x) => x.nodeId === run.pausedNodeId);
1488
2170
  if (!node)
1489
2171
  return [];
1490
2172
  const out = [];
1491
2173
  let n = 0;
1492
2174
  (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
- }
2175
+ if (a.type !== "text")
2176
+ return;
2177
+ n += 1;
2178
+ out.push({
2179
+ n,
2180
+ outputIndex: idx,
2181
+ text: a.text,
2182
+ label: a.label,
2183
+ humanEdited: !!a.humanEdited,
2184
+ ...(a.item ? { item: a.item } : {}),
2185
+ });
1497
2186
  });
1498
2187
  return out;
1499
2188
  }
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");
2189
+ function checkpointItemIndexes(outputs) {
2190
+ const seen = new Set();
2191
+ for (const o of outputs)
2192
+ if (o.item)
2193
+ seen.add(o.item.index);
2194
+ return [...seen].sort((a, b) => a - b);
2195
+ }
2196
+ function checkpointNode(run) {
2197
+ return (run.nodes ?? []).find((x) => x.nodeId === run.pausedNodeId);
2198
+ }
2199
+ function checkpointOutputRow(o, indent) {
2200
+ const label = o.label ? ` [${o.label}]` : "";
2201
+ const edited = o.humanEdited ? " (edited)" : "";
2202
+ return `${indent}${o.n}.${label} ${truncateText(o.text, 200)}${edited}`;
2203
+ }
2204
+ function formatCheckpointOutputs(outputs) {
2205
+ if (outputs.length === 0)
2206
+ return " (this step produced no text to review)";
2207
+ const indexes = checkpointItemIndexes(outputs);
2208
+ if (indexes.length === 0) {
2209
+ return outputs.map((o) => checkpointOutputRow(o, " ")).join("\n");
2210
+ }
2211
+ const lines = [];
2212
+ for (const index of indexes) {
2213
+ const rows = outputs.filter((o) => o.item?.index === index);
2214
+ const total = rows[0]?.item?.total ?? indexes.length;
2215
+ lines.push(` Item ${index + 1} of ${total}`);
2216
+ for (const row of rows)
2217
+ lines.push(checkpointOutputRow(row, " "));
2218
+ }
2219
+ const shared = outputs.filter((o) => !o.item);
2220
+ if (shared.length > 0) {
2221
+ lines.push(" Not tied to an item");
2222
+ for (const row of shared)
2223
+ lines.push(checkpointOutputRow(row, " "));
2224
+ }
2225
+ return lines.join("\n");
1506
2226
  }
1507
- function rangeError(num, count, json) {
2227
+ function checkpointRangeError(num, count, json) {
1508
2228
  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);
2229
+ return errLine(`Output ${num} is out of range — this step has ${count} text output${count === 1 ? "" : "s"}${range}.`, json);
1510
2230
  }
1511
- export async function gateShowFlow(runId, opts, deps) {
1512
- const pf = await preflightPark(runId, "taste", "workflow gate", opts.json, deps);
2231
+ export async function checkpointShowFlow(runId, opts, deps) {
2232
+ const pf = await preflightPark(runId, "checkpoint", "workflow checkpoint", opts.json, deps);
1513
2233
  if (!pf.ok)
1514
2234
  return pf.result;
1515
- const cands = gateCandidates(pf.run);
2235
+ const outputs = checkpointOutputs(pf.run);
2236
+ const node = checkpointNode(pf.run);
2237
+ const itemIndexes = checkpointItemIndexes(outputs);
1516
2238
  if (opts.json) {
1517
2239
  return {
1518
2240
  code: 0,
@@ -1520,53 +2242,108 @@ export async function gateShowFlow(runId, opts, deps) {
1520
2242
  JSON.stringify({
1521
2243
  runId,
1522
2244
  pausedNodeId: pf.run.pausedNodeId,
1523
- candidates: cands.map((c) => ({ n: c.n, text: c.text, humanEdited: c.humanEdited })),
2245
+ pausedNodeKind: node?.kind,
2246
+ ...(node?.botSlug ? { botSlug: node.botSlug } : {}),
2247
+ ...(itemIndexes.length > 0 ? { itemIndexes } : {}),
2248
+ outputs: outputs.map((o) => ({
2249
+ n: o.n,
2250
+ text: o.text,
2251
+ ...(o.label ? { label: o.label } : {}),
2252
+ humanEdited: o.humanEdited,
2253
+ ...(o.item ? { item: o.item } : {}),
2254
+ })),
1524
2255
  }),
1525
2256
  ],
1526
2257
  };
1527
2258
  }
2259
+ const stepLabel = node
2260
+ ? `${node.nodeId} (${node.botSlug ? `${node.kind}: ${node.botSlug}` : node.kind})`
2261
+ : (pf.run.pausedNodeId ?? "-");
2262
+ const itemsNote = itemIndexes.length > 0
2263
+ ? [
2264
+ `This step ran once per item (${itemIndexes.length} of them). Approve keeps every item;`,
2265
+ "reject drops the ones you name and the run carries on with the rest.",
2266
+ "",
2267
+ ]
2268
+ : [];
2269
+ const rejectLine = itemIndexes.length > 0
2270
+ ? [`Reject: exodus workflow checkpoint ${runId} approve --reject 2 --wait`]
2271
+ : [];
1528
2272
  return {
1529
2273
  code: 0,
1530
2274
  lines: [
1531
- `Gate — ${pf.run.workflowName}`,
2275
+ `Checkpoint — ${pf.run.workflowName}`,
1532
2276
  `runId: ${runId}`,
1533
- `gate node: ${pf.run.pausedNodeId ?? "-"}`,
2277
+ `step: ${stepLabel}`,
1534
2278
  "",
1535
- `Candidates (${cands.length}):`,
1536
- formatCandidates(cands),
2279
+ "This step is done and the run is holding here until you say go.",
1537
2280
  "",
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 "..."`,
2281
+ ...itemsNote,
2282
+ `Its output (${outputs.length}):`,
2283
+ formatCheckpointOutputs(outputs),
2284
+ "",
2285
+ `Approve: exodus workflow checkpoint ${runId} approve --wait`,
2286
+ ...rejectLine,
2287
+ `Edit: exodus workflow checkpoint ${runId} edit 1 --text "..."`,
2288
+ `Redo: exodus workflow checkpoint ${runId} retry --wait`,
2289
+ `Cancel: exodus workflow checkpoint ${runId} cancel --reason "..."`,
1542
2290
  ],
1543
2291
  };
1544
2292
  }
1545
- export async function gatePickFlow(runId, numbers, opts, deps) {
1546
- const pf = await preflightPark(runId, "taste", "workflow gate pick", opts.json, deps);
2293
+ export function parseRejectItems(raw) {
2294
+ const bad = (why) => ({
2295
+ ok: false,
2296
+ message: `${why} --reject takes the item numbers you see in "checkpoint show", like --reject 2 or --reject 2,5.`,
2297
+ });
2298
+ if (raw === undefined || raw.trim() === "")
2299
+ return bad("--reject needs at least one item number.");
2300
+ const parts = raw
2301
+ .split(",")
2302
+ .map((s) => s.trim())
2303
+ .filter((s) => s.length > 0);
2304
+ if (parts.length === 0)
2305
+ return bad("--reject needs at least one item number.");
2306
+ const items = [];
2307
+ for (const part of parts) {
2308
+ if (!/^\d+$/.test(part))
2309
+ return bad(`"${part}" isn't an item number.`);
2310
+ const n = Number(part);
2311
+ if (n < 1)
2312
+ return bad("Items are numbered from 1.");
2313
+ if (!items.includes(n))
2314
+ items.push(n);
2315
+ }
2316
+ return { ok: true, items: items.sort((a, b) => a - b) };
2317
+ }
2318
+ export async function checkpointApproveFlow(runId, opts, deps) {
2319
+ let rejectItems;
2320
+ if (opts.reject !== undefined) {
2321
+ const parsed = parseRejectItems(opts.reject);
2322
+ if (!parsed.ok)
2323
+ return errLine(parsed.message, opts.json);
2324
+ rejectItems = parsed.items;
2325
+ }
2326
+ const pf = await preflightPark(runId, "checkpoint", "workflow checkpoint approve", opts.json, deps);
1547
2327
  if (!pf.ok)
1548
2328
  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);
2329
+ if (rejectItems === undefined) {
2330
+ const res = await deps.post(APPROVE_PATH, { runId });
2331
+ if (!res.ok)
2332
+ return triggerErrorResult(res, "workflow checkpoint approve", opts.json);
2333
+ const triggerRunId = res.data.triggerRunId;
2334
+ return resumeAndMaybeWait(runId, triggerRunId, ["Checkpoint approved — the run resumes."], opts, deps);
1558
2335
  }
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
- });
2336
+ const rejections = rejectItems.map((n) => n - 1);
2337
+ const res = await deps.post(CHECKPOINT_RESOLVE_ITEMS_PATH, { runId, rejections });
1565
2338
  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) {
2339
+ return triggerErrorResult(res, "workflow checkpoint approve", opts.json);
2340
+ const data = (res.data ?? {});
2341
+ const rejected = (data.rejected ?? rejections).map((i) => i + 1).sort((a, b) => a - b);
2342
+ const kept = data.remaining ?? 0;
2343
+ const total = kept + rejected.length;
2344
+ return resumeAndMaybeWait(runId, data.triggerRunId, [`Approved — kept ${kept} of ${total} items (rejected: ${rejected.join(", ")}).`], opts, deps, undefined, { rejected, remaining: kept });
2345
+ }
2346
+ export async function checkpointEditFlow(runId, n, sources, opts, deps) {
1570
2347
  const provided = [sources.text, sources.file, sources.stdin].filter((x) => x !== undefined);
1571
2348
  if (provided.length === 0) {
1572
2349
  return errLine("Provide the replacement text via one of --text, --file <path>, or piped stdin.", opts.json);
@@ -1590,80 +2367,38 @@ export async function gateEditFlow(runId, n, sources, opts, deps) {
1590
2367
  else {
1591
2368
  text = sources.stdin;
1592
2369
  }
1593
- const pf = await preflightPark(runId, "taste", "workflow gate edit", opts.json, deps);
2370
+ const pf = await preflightPark(runId, "checkpoint", "workflow checkpoint edit", opts.json, deps);
1594
2371
  if (!pf.ok)
1595
2372
  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, {
2373
+ const outputs = checkpointOutputs(pf.run);
2374
+ const target = outputs.find((o) => o.n === n);
2375
+ if (!target)
2376
+ return checkpointRangeError(n, outputs.length, opts.json);
2377
+ const res = await deps.post(CHECKPOINT_EDIT_PATH, {
1601
2378
  runId,
1602
2379
  nodeId: pf.run.pausedNodeId,
1603
- outputIndex: cand.outputIndex,
2380
+ outputIndex: target.outputIndex,
1604
2381
  text,
1605
2382
  });
1606
2383
  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 };
2384
+ return triggerErrorResult(res, "workflow checkpoint edit", opts.json);
2385
+ return okLine(`Edited output ${n} at ${pf.run.pausedNodeId}. Approve to continue: exodus workflow checkpoint ${runId} approve --wait`, { ok: true, runId, nodeId: pf.run.pausedNodeId, outputIndex: target.outputIndex }, opts.json);
1654
2386
  }
1655
- export async function gateApproveFlow(runId, opts, deps) {
1656
- const pf = await preflightPark(runId, "taste", "workflow gate approve", opts.json, deps);
2387
+ export async function checkpointRetryFlow(runId, opts, deps) {
2388
+ const pf = await preflightPark(runId, "checkpoint", "workflow checkpoint retry", opts.json, deps);
1657
2389
  if (!pf.ok)
1658
2390
  return pf.result;
1659
- const res = await deps.post(APPROVE_PATH, { runId });
2391
+ const res = await deps.post(CHECKPOINT_RETRY_PATH, { runId });
1660
2392
  if (!res.ok)
1661
- return triggerErrorResult(res, "workflow gate approve", opts.json);
2393
+ return triggerErrorResult(res, "workflow checkpoint retry", opts.json);
1662
2394
  const triggerRunId = res.data.triggerRunId;
1663
- return resumeAndMaybeWait(runId, triggerRunId, ["Gate approved — the run resumes."], opts, deps);
2395
+ return resumeAndMaybeWait(runId, triggerRunId, ["Redoing the step — its old output is discarded."], opts, deps, {
2396
+ pauseReason: "checkpoint",
2397
+ headline: " ⏸ The step re-ran — its fresh output is waiting on your approval.",
2398
+ });
1664
2399
  }
1665
- export async function gateRejectFlow(runId, opts, deps) {
1666
- const pf = await preflightPark(runId, "taste", "workflow gate reject", opts.json, deps);
2400
+ export async function checkpointCancelFlow(runId, opts, deps) {
2401
+ const pf = await preflightPark(runId, ["checkpoint", "taste"], "workflow checkpoint cancel", opts.json, deps);
1667
2402
  if (!pf.ok)
1668
2403
  return pf.result;
1669
2404
  const res = await deps.post(CANCEL_PATH, {
@@ -1671,8 +2406,8 @@ export async function gateRejectFlow(runId, opts, deps) {
1671
2406
  ...(opts.reason !== undefined ? { reason: opts.reason } : {}),
1672
2407
  });
1673
2408
  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);
2409
+ return triggerErrorResult(res, "workflow checkpoint cancel", opts.json);
2410
+ return okLine(`Checkpoint canceled — run ${runId} stopped.`, { ok: true, runId }, opts.json);
1676
2411
  }
1677
2412
  export async function repairFlow(runId, action, opts, deps) {
1678
2413
  const verb = `workflow repair ${action}`;
@@ -1692,8 +2427,8 @@ export async function repairFlow(runId, action, opts, deps) {
1692
2427
  const triggerRunId = res.data.triggerRunId;
1693
2428
  return resumeAndMaybeWait(runId, triggerRunId, [`Repair ${action} started — the run resumes.`], opts, deps);
1694
2429
  }
1695
- async function resumeAndMaybeWait(runId, triggerRunId, headline, opts, deps) {
1696
- const base = { runId, triggerRunId };
2430
+ async function resumeAndMaybeWait(runId, triggerRunId, headline, opts, deps, landOnPark, extraJson) {
2431
+ const base = { runId, triggerRunId, ...(extraJson ?? {}) };
1697
2432
  const startLines = [
1698
2433
  ...headline,
1699
2434
  `runId: ${runId}`,
@@ -1709,7 +2444,12 @@ async function resumeAndMaybeWait(runId, triggerRunId, headline, opts, deps) {
1709
2444
  for (const line of startLines)
1710
2445
  opts.onProgressLine(line);
1711
2446
  }
1712
- const waited = await waitForRun(runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base }, deps);
2447
+ const waited = await waitForRun(runId, {
2448
+ json: opts.json,
2449
+ onProgressLine: opts.onProgressLine,
2450
+ jsonBase: base,
2451
+ ...(landOnPark ? { landOnPark } : {}),
2452
+ }, deps);
1713
2453
  if (opts.json)
1714
2454
  return waited;
1715
2455
  const prefix = opts.onProgressLine ? [] : startLines;
@@ -1793,20 +2533,6 @@ async function printResult(result) {
1793
2533
  if (result.code !== 0)
1794
2534
  process.exit(result.code);
1795
2535
  }
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
2536
  async function readAllStdin() {
1811
2537
  const chunks = [];
1812
2538
  for await (const chunk of process.stdin)
@@ -1880,14 +2606,16 @@ export async function run(flags) {
1880
2606
  const workflowRef = rest[0];
1881
2607
  if (!workflowRef) {
1882
2608
  console.error("Error: workflow run requires <workflowId|name>.");
1883
- console.log("Usage: exodus workflow run <workflowId|name> [--input key=value ...] [--wait] [--json]");
2609
+ console.log("Usage: exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=./path/to/file ...] [--wait] [--json]");
1884
2610
  process.exit(1);
1885
2611
  }
1886
2612
  let inputs;
1887
2613
  let terminalNodeIds;
2614
+ let fill;
1888
2615
  try {
1889
- inputs = parseInputFlags(process.argv.slice(3), defaultDeps.readFile);
2616
+ inputs = parseRawInputFlags(process.argv.slice(3));
1890
2617
  terminalNodeIds = parseTerminalFlags(process.argv.slice(3));
2618
+ fill = parseFillFlag(process.argv.slice(3));
1891
2619
  }
1892
2620
  catch (e) {
1893
2621
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -1896,13 +2624,15 @@ export async function run(flags) {
1896
2624
  return printResult(await runFlow(workflowRef, {
1897
2625
  inputs,
1898
2626
  terminalNodeIds,
2627
+ fill,
1899
2628
  wait: flags["wait"] === true,
1900
2629
  json,
2630
+ out: flagString(flags, "out"),
1901
2631
  onProgressLine: (line) => console.log(line),
1902
2632
  }, defaultDeps));
1903
2633
  }
1904
2634
  if (sub === "status") {
1905
- return printResult(await statusFlow({ id: flagString(flags, "id"), json }, defaultDeps));
2635
+ return printResult(await statusFlow({ id: flagString(flags, "id"), json, out: flagString(flags, "out") }, defaultDeps));
1906
2636
  }
1907
2637
  if (sub === "versions") {
1908
2638
  const workflowRef = rest[0];
@@ -1943,48 +2673,47 @@ export async function run(flags) {
1943
2673
  return printResult(await inboxFlow(json, defaultDeps));
1944
2674
  }
1945
2675
  if (sub === "gate") {
2676
+ console.error(RETIRED_GATE_VERB_POINTER);
2677
+ process.exit(1);
2678
+ }
2679
+ if (sub === "checkpoint") {
1946
2680
  const runId = rest[0];
1947
2681
  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]");
2682
+ console.error("Error: workflow checkpoint requires <runId>.");
2683
+ console.log('Usage: exodus workflow checkpoint <runId> [show | edit <n> | approve [--reject <n,..>] | retry | cancel]');
1950
2684
  process.exit(1);
1951
2685
  }
1952
2686
  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));
2687
+ if (!action || action === "show") {
2688
+ return printResult(await checkpointShowFlow(runId, { json }, defaultDeps));
1962
2689
  }
1963
2690
  if (action === "edit") {
1964
2691
  const n = parseTriggerIndex(rest[2]);
1965
2692
  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)]`);
2693
+ console.error("Error: checkpoint edit needs a 1-based output number <n>.");
2694
+ console.log(`Usage: exodus workflow checkpoint <runId> edit <n> [--text "..." | --file <path> | (stdin)]`);
1968
2695
  process.exit(1);
1969
2696
  }
1970
2697
  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));
2698
+ return printResult(await checkpointEditFlow(runId, n, { text: flagString(flags, "text"), file: flagString(flags, "file"), stdin }, { json }, defaultDeps));
1980
2699
  }
1981
2700
  if (action === "approve") {
1982
- return printResult(await gateApproveFlow(runId, { wait: flags["wait"] === true, json, onProgressLine: (line) => console.log(line) }, defaultDeps));
2701
+ return printResult(await checkpointApproveFlow(runId, {
2702
+ wait: flags["wait"] === true,
2703
+ json,
2704
+ onProgressLine: (line) => console.log(line),
2705
+ ...(flags["reject"] !== undefined
2706
+ ? { reject: flagString(flags, "reject") ?? "" }
2707
+ : {}),
2708
+ }, defaultDeps));
2709
+ }
2710
+ if (action === "retry") {
2711
+ return printResult(await checkpointRetryFlow(runId, { wait: flags["wait"] === true, json, onProgressLine: (line) => console.log(line) }, defaultDeps));
1983
2712
  }
1984
- if (action === "reject") {
1985
- return printResult(await gateRejectFlow(runId, { reason: flagString(flags, "reason"), json }, defaultDeps));
2713
+ if (action === "cancel") {
2714
+ return printResult(await checkpointCancelFlow(runId, { reason: flagString(flags, "reason"), json }, defaultDeps));
1986
2715
  }
1987
- console.error(`Error: unknown gate action "${action}" (expected pick, edit, push, approve, or reject).`);
2716
+ console.error(`Error: unknown checkpoint action "${action}" (expected show, edit, approve, retry, or cancel).`);
1988
2717
  process.exit(1);
1989
2718
  }
1990
2719
  if (sub === "repair") {