@aicopycoders/exodus 2026.7.200-beta.13 → 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
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,8 +260,33 @@ 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 {
@@ -278,17 +364,20 @@ function outputLines(output) {
278
364
  function progressLine(node, parked = false) {
279
365
  const err = node.error ? ` — error: ${node.error}` : "";
280
366
  if (parked) {
281
- return ` ⏸ ${node.nodeId} (${node.kind}) awaiting review`;
367
+ return ` ⏸ ${node.nodeId} (${node.kind}) awaiting approval`;
282
368
  }
283
369
  return ` ${statusIcon(node.status)} ${node.nodeId} (${node.kind}) ${node.status}${err}`;
284
370
  }
285
371
  function gateParkedNodeId(status, pauseReason, pausedNodeId) {
286
- if (status !== "awaiting-review")
372
+ if (!status || normalizeRunStatus(status) !== "awaiting-approval")
287
373
  return undefined;
288
- if (pauseReason === "taste" || pauseReason === undefined)
374
+ if (pauseReason === "taste" || pauseReason === "checkpoint" || pauseReason === undefined) {
289
375
  return pausedNodeId;
376
+ }
290
377
  return undefined;
291
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]";
292
381
  export function formatPauseNotice(pauseReason, runId, dashboardUrl) {
293
382
  if (!pauseReason) {
294
383
  return [
@@ -298,12 +387,24 @@ export function formatPauseNotice(pauseReason, runId, dashboardUrl) {
298
387
  if (pauseReason === "call") {
299
388
  return [" ⏸ waiting on a child workflow run — it resumes on its own."];
300
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
+ }
301
405
  const resolveVerb = pauseReason === "repair"
302
406
  ? `exodus workflow repair ${runId} retry|skip|kill`
303
- : pauseReason === "slots"
304
- ? `exodus workflow answer ${runId} --slot key=value`
305
- :
306
- `exodus workflow gate ${runId}`;
407
+ : `exodus workflow answer ${runId} --slot key=value`;
307
408
  return [
308
409
  " ⏸ paused for review — waiting on you.",
309
410
  ` Resolve here: ${resolveVerb}`,
@@ -331,7 +432,7 @@ function expandInputValue(key, value, readFile) {
331
432
  }
332
433
  return value;
333
434
  }
334
- export function parseInputFlags(args, readFile) {
435
+ export function parseRawInputFlags(args) {
335
436
  const inputs = {};
336
437
  for (let i = 0; i < args.length; i++) {
337
438
  const arg = args[i];
@@ -354,10 +455,259 @@ export function parseInputFlags(args, readFile) {
354
455
  const key = raw.slice(0, eq).trim();
355
456
  if (!key)
356
457
  throw new Error(`--input must include a key (got "${raw}")`);
357
- inputs[key] = expandInputValue(key, raw.slice(eq + 1), readFile);
458
+ inputs[key] = raw.slice(eq + 1);
358
459
  }
359
460
  return inputs;
360
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);
466
+ }
467
+ return inputs;
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
+ }
361
711
  export function parseTerminalFlags(args) {
362
712
  const ids = [];
363
713
  for (let i = 0; i < args.length; i++) {
@@ -382,6 +732,31 @@ export function parseTerminalFlags(args) {
382
732
  }
383
733
  return ids;
384
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
+ }
385
760
  export function formatWorkflowList(workflows) {
386
761
  if (workflows.length === 0)
387
762
  return "No workflows found for the active brand.";
@@ -396,7 +771,7 @@ export function formatWorkflowList(workflows) {
396
771
  export function formatRecentRuns(runs) {
397
772
  if (runs.length === 0)
398
773
  return "No workflow runs found for the active brand.";
399
- 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]));
400
775
  }
401
776
  export function formatWorkflowVersions(versions) {
402
777
  if (versions.length === 0) {
@@ -474,7 +849,7 @@ export function formatWorkflowRun(run) {
474
849
  lines.push(`workflowId: ${run.workflowId}`);
475
850
  if (run.triggerRunId)
476
851
  lines.push(`triggerRunId: ${run.triggerRunId}`);
477
- lines.push(`verdict: ${run.status}${counts ? ` (${counts})` : ""}`);
852
+ lines.push(`verdict: ${runStatusLabel(run.status)}${counts ? ` (${counts})` : ""}`);
478
853
  if (run.isTerminal)
479
854
  lines.push("terminal: yes");
480
855
  if (run.error)
@@ -493,6 +868,12 @@ export function formatWorkflowRun(run) {
493
868
  lines.push(...outputLines(output));
494
869
  }
495
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
+ }
496
877
  if (run.outputs && run.outputs.length > 0) {
497
878
  lines.push("");
498
879
  lines.push(`Outputs (${run.outputs.length}):`);
@@ -511,7 +892,7 @@ export function formatWorkflowRun(run) {
511
892
  }
512
893
  function runOutputLines(output, awaitingReview = false) {
513
894
  const slug = output.botSlug ? ` (${output.botSlug})` : "";
514
- const review = awaitingReview ? " — awaiting review, not yet approved" : "";
895
+ const review = awaitingReview ? " — awaiting approval, not yet approved" : "";
515
896
  if (output.type === "image") {
516
897
  return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}${review}`];
517
898
  }
@@ -522,6 +903,12 @@ function runOutputLines(output, awaitingReview = false) {
522
903
  if (output.type === "audio") {
523
904
  return [` ${output.label} [audio]${slug}: ${output.audioUrl ?? "(no url)"}${review}`];
524
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
+ ];
911
+ }
525
912
  if (output.type === "frames") {
526
913
  const n = output.frames?.length ?? 0;
527
914
  return [` ${output.label} [frames]${slug}: ${n} scene${n === 1 ? "" : "s"}${review}`];
@@ -535,6 +922,140 @@ function runOutputLines(output, awaitingReview = false) {
535
922
  const note = normalized.length > 400 ? "\n (truncated — use --json for the full text)" : "";
536
923
  return [` ${output.label} [text]${slug}${review}:`, ` ${body}${note}`];
537
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;
1058
+ }
538
1059
  export function formatDescribe(res) {
539
1060
  const lines = [];
540
1061
  lines.push(`Workflow — ${res.name}`);
@@ -550,8 +1071,13 @@ export function formatDescribe(res) {
550
1071
  else {
551
1072
  for (const input of res.inputs) {
552
1073
  const req = input.required ? "required" : "optional";
1074
+ const type = input.type && input.type !== "text" ? ` (${input.type})` : "";
553
1075
  const bundle = input.bundleSize !== undefined ? `, bundle=${input.bundleSize}` : "";
554
- 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}`);
555
1081
  if (input.description)
556
1082
  lines.push(` ${input.description}`);
557
1083
  }
@@ -721,9 +1247,12 @@ function schemaValueLines(value, indent) {
721
1247
  if (isRecord(el)) {
722
1248
  const label = schemaEntryLabel(el);
723
1249
  if (label) {
724
- lines.push(`${indent}- ${label.value}`);
1250
+ const parked = el["parked"] === true;
1251
+ lines.push(`${indent}- ${label.value}${parked ? " (parked)" : ""}`);
725
1252
  const rest = { ...el };
726
1253
  delete rest[label.key];
1254
+ if (parked)
1255
+ delete rest["parked"];
727
1256
  lines.push(...schemaValueLines(rest, `${indent} `));
728
1257
  }
729
1258
  else {
@@ -760,7 +1289,6 @@ const SCHEMA_SECTIONS = [
760
1289
  { title: "Port types", keys: ["portTypes"] },
761
1290
  { title: "Node kinds", keys: ["nodeKinds"] },
762
1291
  { title: "Transform faces", keys: ["transformFaces"] },
763
- { title: "Gate policies", keys: ["gatePolicies"] },
764
1292
  { title: "Collector policy", keys: ["collectorPolicy"] },
765
1293
  { title: "Deposits", keys: ["deposits"] },
766
1294
  { title: "Slots", keys: ["slots"] },
@@ -946,9 +1474,33 @@ export async function runFlow(workflowRef, opts, deps) {
946
1474
  catch (e) {
947
1475
  return resolveIdErrorResult(e, opts.json ?? false);
948
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
+ };
1499
+ }
949
1500
  const body = {
950
1501
  workflowId,
951
- ...(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() } : {}),
952
1504
  ...(opts.terminalNodeIds && opts.terminalNodeIds.length > 0
953
1505
  ? { terminalNodeIds: opts.terminalNodeIds }
954
1506
  : {}),
@@ -963,35 +1515,54 @@ export async function runFlow(workflowRef, opts, deps) {
963
1515
  const lines = opts.json
964
1516
  ? []
965
1517
  : [
1518
+ ...preface,
966
1519
  "Workflow run started.",
967
1520
  `runId: ${data.runId}`,
968
1521
  `triggerRunId: ${data.triggerRunId}`,
969
1522
  `Poll: exodus workflow status --id ${data.runId}`,
970
1523
  ];
971
- 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
+ }
972
1528
  return { code: 0, lines };
1529
+ }
973
1530
  if (!opts.json && opts.onProgressLine) {
974
1531
  for (const line of lines)
975
1532
  opts.onProgressLine(line);
976
1533
  lines.length = 0;
977
1534
  }
978
- 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);
979
1536
  if (opts.json)
980
1537
  return waited;
981
1538
  return { code: waited.code, lines: [...lines, ...waited.lines] };
982
1539
  }
1540
+ const WAIT_TERMINAL_STATUSES = TERMINAL_RUN_STATUSES.flatMap((s) => storedWorkflowStatusForms(s));
983
1541
  async function waitForRun(runId, opts, deps) {
984
1542
  const seen = new Map();
985
1543
  let pausedNotified = false;
1544
+ const landOnPark = opts.landOnPark;
986
1545
  const pollResult = await deps.poll({
987
1546
  path: `${STATUS_PATH}?runId=${encodeURIComponent(runId)}`,
988
1547
  intervalMs: 3_000,
989
1548
  timeoutMs: 60 * 60 * 1000,
990
- 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
+ : {}),
991
1558
  onProgress: (raw) => {
992
1559
  if (opts.json || !opts.onProgressLine)
993
1560
  return;
994
- 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) {
995
1566
  pausedNotified = true;
996
1567
  const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
997
1568
  const pauseReason = raw["pauseReason"];
@@ -1011,11 +1582,24 @@ async function waitForRun(runId, opts, deps) {
1011
1582
  }
1012
1583
  },
1013
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;
1014
1593
  if (opts.json) {
1015
1594
  return {
1016
1595
  code: pollResult.ok ? 0 : 1,
1017
1596
  lines: [
1018
- 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
+ }),
1019
1603
  ],
1020
1604
  };
1021
1605
  }
@@ -1026,23 +1610,50 @@ async function waitForRun(runId, opts, deps) {
1026
1610
  };
1027
1611
  }
1028
1612
  const lines = [""];
1029
- if (isRecord(pollResult.data) && typeof pollResult.data["_id"] === "string") {
1030
- lines.push(formatWorkflowRun(pollResult.data));
1613
+ if (terminalRun) {
1614
+ lines.push(formatWorkflowRun(terminalRun));
1031
1615
  }
1032
1616
  else {
1033
1617
  lines.push(`Polling failed: ${JSON.stringify(pollResult.data)}`);
1034
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
+ }
1035
1629
  return { code: pollResult.ok ? 0 : 1, lines };
1036
1630
  }
1037
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
+ }
1038
1640
  const path = opts.id ? `${STATUS_PATH}?runId=${encodeURIComponent(opts.id)}` : STATUS_PATH;
1039
1641
  const res = await deps.get(path);
1040
1642
  if (!res.ok)
1041
1643
  return asErrorResult(res, opts.json);
1042
- if (opts.json)
1043
- return { code: 0, lines: [JSON.stringify(res.data)] };
1044
- if (opts.id)
1045
- 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
+ }
1046
1657
  const runs = (res.data.runs ?? []);
1047
1658
  return { code: 0, lines: [formatRecentRuns(runs)] };
1048
1659
  }
@@ -1457,6 +2068,8 @@ export function parkBadge(pauseReason) {
1457
2068
  return "repair";
1458
2069
  if (pauseReason === "slots")
1459
2070
  return "slots";
2071
+ if (pauseReason === "checkpoint")
2072
+ return "checkpoint";
1460
2073
  return "legacy";
1461
2074
  }
1462
2075
  export function invocationBadge(row) {
@@ -1500,44 +2113,36 @@ function errLine(message, json, status) {
1500
2113
  function okLine(message, payload, json) {
1501
2114
  return { code: 0, lines: [json ? JSON.stringify(payload) : message] };
1502
2115
  }
1503
- function routeErrorText(data, status) {
1504
- if (isRecord(data)) {
1505
- const err = data.error;
1506
- if (typeof err === "string" && err)
1507
- return err;
1508
- if (isRecord(err) && typeof err.message === "string")
1509
- return err.message;
1510
- if (typeof data.message === "string" && data.message)
1511
- return data.message;
1512
- }
1513
- return `HTTP ${status}`;
1514
- }
1515
2116
  function asWorkflowRun(data) {
1516
2117
  return isRecord(data) && typeof data["_id"] === "string"
1517
2118
  ? data
1518
2119
  : undefined;
1519
2120
  }
1520
2121
  function describePark(run) {
1521
- if (run.status !== "awaiting-review")
1522
- return `status: ${run.status}`;
2122
+ if (normalizeRunStatus(run.status) !== "awaiting-approval") {
2123
+ return `status: ${runStatusLabel(run.status)}`;
2124
+ }
1523
2125
  switch (run.pauseReason) {
1524
2126
  case "taste":
1525
- return "parked at a gate (taste review)";
2127
+ return "parked at a Gate box, which retired in 2.0 (cancel and re-run)";
1526
2128
  case "repair":
1527
2129
  return "parked for repair";
1528
2130
  case "slots":
1529
2131
  return "parked for slot answers";
1530
2132
  case "call":
1531
2133
  return "parked on a child workflow";
2134
+ case "checkpoint":
2135
+ return `parked at a checkpoint (use: exodus workflow checkpoint ${run._id})`;
1532
2136
  default:
1533
- return "parked at the cost gate (legacy — no text gate)";
2137
+ return "parked at the cost gate (legacy)";
1534
2138
  }
1535
2139
  }
1536
2140
  const PARK_LABEL = {
1537
- taste: "a gate review",
2141
+ taste: "a retired Gate review",
1538
2142
  repair: "a repair",
1539
2143
  slots: "slot answers",
1540
2144
  call: "a child workflow",
2145
+ checkpoint: "a checkpoint approval",
1541
2146
  };
1542
2147
  async function preflightPark(runId, expected, verb, json, deps) {
1543
2148
  const res = await deps.get(`${STATUS_PATH}?runId=${encodeURIComponent(runId)}`);
@@ -1547,44 +2152,89 @@ async function preflightPark(runId, expected, verb, json, deps) {
1547
2152
  if (!run) {
1548
2153
  return { ok: false, result: errLine(`Could not read run ${runId}.`, json) };
1549
2154
  }
1550
- 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)) {
1551
2161
  return {
1552
2162
  ok: false,
1553
- 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),
1554
2164
  };
1555
2165
  }
1556
2166
  return { ok: true, run };
1557
2167
  }
1558
- function gateCandidates(run) {
2168
+ function checkpointOutputs(run) {
1559
2169
  const node = (run.nodes ?? []).find((x) => x.nodeId === run.pausedNodeId);
1560
2170
  if (!node)
1561
2171
  return [];
1562
2172
  const out = [];
1563
2173
  let n = 0;
1564
2174
  (node.outputs ?? []).forEach((a, idx) => {
1565
- if (a.type === "text" && a.port === "selection") {
1566
- n += 1;
1567
- out.push({ n, outputIndex: idx, text: a.text, humanEdited: !!a.humanEdited });
1568
- }
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
+ });
1569
2186
  });
1570
2187
  return out;
1571
2188
  }
1572
- function formatCandidates(cands) {
1573
- if (cands.length === 0)
1574
- return " (this gate has no selection candidates)";
1575
- return cands
1576
- .map((c) => ` ${c.n}. ${truncateText(c.text, 200)}${c.humanEdited ? " (edited)" : ""}`)
1577
- .join("\n");
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");
1578
2226
  }
1579
- function rangeError(num, count, json) {
2227
+ function checkpointRangeError(num, count, json) {
1580
2228
  const range = count > 0 ? ` (valid: 1–${count})` : "";
1581
- return errLine(`Candidate ${num} is out of range — this gate has ${count} candidate${count === 1 ? "" : "s"}${range}.`, json);
2229
+ return errLine(`Output ${num} is out of range — this step has ${count} text output${count === 1 ? "" : "s"}${range}.`, json);
1582
2230
  }
1583
- export async function gateShowFlow(runId, opts, deps) {
1584
- 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);
1585
2233
  if (!pf.ok)
1586
2234
  return pf.result;
1587
- const cands = gateCandidates(pf.run);
2235
+ const outputs = checkpointOutputs(pf.run);
2236
+ const node = checkpointNode(pf.run);
2237
+ const itemIndexes = checkpointItemIndexes(outputs);
1588
2238
  if (opts.json) {
1589
2239
  return {
1590
2240
  code: 0,
@@ -1592,53 +2242,108 @@ export async function gateShowFlow(runId, opts, deps) {
1592
2242
  JSON.stringify({
1593
2243
  runId,
1594
2244
  pausedNodeId: pf.run.pausedNodeId,
1595
- 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
+ })),
1596
2255
  }),
1597
2256
  ],
1598
2257
  };
1599
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
+ : [];
1600
2272
  return {
1601
2273
  code: 0,
1602
2274
  lines: [
1603
- `Gate — ${pf.run.workflowName}`,
2275
+ `Checkpoint — ${pf.run.workflowName}`,
1604
2276
  `runId: ${runId}`,
1605
- `gate node: ${pf.run.pausedNodeId ?? "-"}`,
2277
+ `step: ${stepLabel}`,
1606
2278
  "",
1607
- `Candidates (${cands.length}):`,
1608
- formatCandidates(cands),
2279
+ "This step is done and the run is holding here until you say go.",
1609
2280
  "",
1610
- `Pick: exodus workflow gate ${runId} pick 1,2`,
1611
- `Edit: exodus workflow gate ${runId} edit 1 --text "..."`,
1612
- `Approve: exodus workflow gate ${runId} approve --wait`,
1613
- `Reject: exodus workflow gate ${runId} reject --reason "..."`,
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 "..."`,
1614
2290
  ],
1615
2291
  };
1616
2292
  }
1617
- export async function gatePickFlow(runId, numbers, opts, deps) {
1618
- 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);
1619
2327
  if (!pf.ok)
1620
2328
  return pf.result;
1621
- const cands = gateCandidates(pf.run);
1622
- const seen = new Set();
1623
- for (const num of numbers) {
1624
- if (!Number.isInteger(num) || num < 1 || num > cands.length) {
1625
- return rangeError(num, cands.length, opts.json);
1626
- }
1627
- if (seen.has(num))
1628
- return errLine(`Candidate ${num} is listed twice.`, opts.json);
1629
- seen.add(num);
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);
1630
2335
  }
1631
- const selectedIndices = numbers.map((num) => num - 1);
1632
- const res = await deps.post(GATE_PICK_PATH, {
1633
- runId,
1634
- nodeId: pf.run.pausedNodeId,
1635
- selectedIndices,
1636
- });
2336
+ const rejections = rejectItems.map((n) => n - 1);
2337
+ const res = await deps.post(CHECKPOINT_RESOLVE_ITEMS_PATH, { runId, rejections });
1637
2338
  if (!res.ok)
1638
- return triggerErrorResult(res, "workflow gate pick", opts.json);
1639
- return okLine(`Picked candidate${numbers.length === 1 ? "" : "s"} ${numbers.join(", ")} at ${pf.run.pausedNodeId}. Approve to resume: exodus workflow gate ${runId} approve --wait`, { ok: true, runId, nodeId: pf.run.pausedNodeId, selectedIndices }, opts.json);
1640
- }
1641
- export async function gateEditFlow(runId, n, sources, opts, deps) {
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) {
1642
2347
  const provided = [sources.text, sources.file, sources.stdin].filter((x) => x !== undefined);
1643
2348
  if (provided.length === 0) {
1644
2349
  return errLine("Provide the replacement text via one of --text, --file <path>, or piped stdin.", opts.json);
@@ -1662,91 +2367,38 @@ export async function gateEditFlow(runId, n, sources, opts, deps) {
1662
2367
  else {
1663
2368
  text = sources.stdin;
1664
2369
  }
1665
- 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);
1666
2371
  if (!pf.ok)
1667
2372
  return pf.result;
1668
- const cands = gateCandidates(pf.run);
1669
- const cand = cands.find((c) => c.n === n);
1670
- if (!cand)
1671
- return rangeError(n, cands.length, opts.json);
1672
- const res = await deps.post(GATE_EDIT_PATH, {
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, {
1673
2378
  runId,
1674
2379
  nodeId: pf.run.pausedNodeId,
1675
- outputIndex: cand.outputIndex,
2380
+ outputIndex: target.outputIndex,
1676
2381
  text,
1677
2382
  });
1678
2383
  if (!res.ok)
1679
- return triggerErrorResult(res, "workflow gate edit", opts.json);
1680
- return okLine(`Edited candidate ${n} at ${pf.run.pausedNodeId}.`, { ok: true, runId, nodeId: pf.run.pausedNodeId, outputIndex: cand.outputIndex }, opts.json);
1681
- }
1682
- function resolveGateSessionId(run) {
1683
- const gateNode = (run.nodes ?? []).find((x) => x.nodeId === run.pausedNodeId);
1684
- const artifact = (gateNode?.outputs ?? []).find((a) => a.type === "session" && a.port === "session" && !!a.sessionId);
1685
- if (artifact)
1686
- return artifact.sessionId;
1687
- const sessions = run.sessions ?? [];
1688
- const byNode = sessions.find((s) => s.nodeId === run.pausedNodeId);
1689
- if (byNode)
1690
- return byNode.sessionId;
1691
- return undefined;
1692
- }
1693
- export async function gatePushFlow(runId, message, opts, deps) {
1694
- const pf = await preflightPark(runId, "taste", "workflow gate push", opts.json, deps);
1695
- if (!pf.ok)
1696
- return pf.result;
1697
- const sessionId = resolveGateSessionId(pf.run);
1698
- if (!sessionId)
1699
- return errLine("This gate has no live session — nothing to push to.", opts.json);
1700
- if (!deps.postDashboard) {
1701
- return errLine("gate push is unavailable here (no dashboard client).", opts.json);
1702
- }
1703
- const chat = await deps.postDashboard(CHAT_PATH, { sessionId, text: message }, { timeoutMs: CHAT_TIMEOUT_MS });
1704
- if (!chat.ok) {
1705
- const err = routeErrorText(chat.data, chat.status);
1706
- return {
1707
- code: 1,
1708
- lines: opts.json
1709
- ? [JSON.stringify({ ok: false, status: chat.status, error: err })]
1710
- : [`exodus workflow gate push: ${err} (HTTP ${chat.status})`],
1711
- };
1712
- }
1713
- const res = await deps.post(GATE_APPEND_PATH, { runId, nodeId: pf.run.pausedNodeId });
1714
- if (!res.ok)
1715
- return triggerErrorResult(res, "workflow gate push", opts.json);
1716
- const after = await preflightPark(runId, "taste", "workflow gate push", opts.json, deps);
1717
- const cands = after.ok ? gateCandidates(after.run) : [];
1718
- const newest = cands[cands.length - 1];
1719
- if (opts.json) {
1720
- return {
1721
- code: 0,
1722
- lines: [
1723
- JSON.stringify({
1724
- ok: true,
1725
- runId,
1726
- nodeId: pf.run.pausedNodeId,
1727
- candidate: newest ? { n: newest.n, text: newest.text } : null,
1728
- }),
1729
- ],
1730
- };
1731
- }
1732
- const lines = [`Pushed to the gate — banked a new candidate at ${pf.run.pausedNodeId}.`];
1733
- if (newest) {
1734
- lines.push("", `New candidate:`, ` ${newest.n}. ${truncateText(newest.text, 200)}`);
1735
- }
1736
- return { code: 0, lines };
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);
1737
2386
  }
1738
- export async function gateApproveFlow(runId, opts, deps) {
1739
- 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);
1740
2389
  if (!pf.ok)
1741
2390
  return pf.result;
1742
- const res = await deps.post(APPROVE_PATH, { runId });
2391
+ const res = await deps.post(CHECKPOINT_RETRY_PATH, { runId });
1743
2392
  if (!res.ok)
1744
- return triggerErrorResult(res, "workflow gate approve", opts.json);
2393
+ return triggerErrorResult(res, "workflow checkpoint retry", opts.json);
1745
2394
  const triggerRunId = res.data.triggerRunId;
1746
- 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
+ });
1747
2399
  }
1748
- export async function gateRejectFlow(runId, opts, deps) {
1749
- 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);
1750
2402
  if (!pf.ok)
1751
2403
  return pf.result;
1752
2404
  const res = await deps.post(CANCEL_PATH, {
@@ -1754,8 +2406,8 @@ export async function gateRejectFlow(runId, opts, deps) {
1754
2406
  ...(opts.reason !== undefined ? { reason: opts.reason } : {}),
1755
2407
  });
1756
2408
  if (!res.ok)
1757
- return triggerErrorResult(res, "workflow gate reject", opts.json);
1758
- return okLine(`Gate rejected — run ${runId} canceled.`, { ok: true, runId }, opts.json);
2409
+ return triggerErrorResult(res, "workflow checkpoint cancel", opts.json);
2410
+ return okLine(`Checkpoint canceled — run ${runId} stopped.`, { ok: true, runId }, opts.json);
1759
2411
  }
1760
2412
  export async function repairFlow(runId, action, opts, deps) {
1761
2413
  const verb = `workflow repair ${action}`;
@@ -1775,8 +2427,8 @@ export async function repairFlow(runId, action, opts, deps) {
1775
2427
  const triggerRunId = res.data.triggerRunId;
1776
2428
  return resumeAndMaybeWait(runId, triggerRunId, [`Repair ${action} started — the run resumes.`], opts, deps);
1777
2429
  }
1778
- async function resumeAndMaybeWait(runId, triggerRunId, headline, opts, deps) {
1779
- const base = { runId, triggerRunId };
2430
+ async function resumeAndMaybeWait(runId, triggerRunId, headline, opts, deps, landOnPark, extraJson) {
2431
+ const base = { runId, triggerRunId, ...(extraJson ?? {}) };
1780
2432
  const startLines = [
1781
2433
  ...headline,
1782
2434
  `runId: ${runId}`,
@@ -1792,7 +2444,12 @@ async function resumeAndMaybeWait(runId, triggerRunId, headline, opts, deps) {
1792
2444
  for (const line of startLines)
1793
2445
  opts.onProgressLine(line);
1794
2446
  }
1795
- 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);
1796
2453
  if (opts.json)
1797
2454
  return waited;
1798
2455
  const prefix = opts.onProgressLine ? [] : startLines;
@@ -1876,20 +2533,6 @@ async function printResult(result) {
1876
2533
  if (result.code !== 0)
1877
2534
  process.exit(result.code);
1878
2535
  }
1879
- function parsePickNumbers(raw) {
1880
- if (raw === undefined)
1881
- return undefined;
1882
- const parts = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1883
- if (parts.length === 0)
1884
- return undefined;
1885
- const nums = [];
1886
- for (const p of parts) {
1887
- if (!/^\d+$/.test(p))
1888
- return undefined;
1889
- nums.push(Number(p));
1890
- }
1891
- return nums;
1892
- }
1893
2536
  async function readAllStdin() {
1894
2537
  const chunks = [];
1895
2538
  for await (const chunk of process.stdin)
@@ -1963,14 +2606,16 @@ export async function run(flags) {
1963
2606
  const workflowRef = rest[0];
1964
2607
  if (!workflowRef) {
1965
2608
  console.error("Error: workflow run requires <workflowId|name>.");
1966
- 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]");
1967
2610
  process.exit(1);
1968
2611
  }
1969
2612
  let inputs;
1970
2613
  let terminalNodeIds;
2614
+ let fill;
1971
2615
  try {
1972
- inputs = parseInputFlags(process.argv.slice(3), defaultDeps.readFile);
2616
+ inputs = parseRawInputFlags(process.argv.slice(3));
1973
2617
  terminalNodeIds = parseTerminalFlags(process.argv.slice(3));
2618
+ fill = parseFillFlag(process.argv.slice(3));
1974
2619
  }
1975
2620
  catch (e) {
1976
2621
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -1979,13 +2624,15 @@ export async function run(flags) {
1979
2624
  return printResult(await runFlow(workflowRef, {
1980
2625
  inputs,
1981
2626
  terminalNodeIds,
2627
+ fill,
1982
2628
  wait: flags["wait"] === true,
1983
2629
  json,
2630
+ out: flagString(flags, "out"),
1984
2631
  onProgressLine: (line) => console.log(line),
1985
2632
  }, defaultDeps));
1986
2633
  }
1987
2634
  if (sub === "status") {
1988
- 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));
1989
2636
  }
1990
2637
  if (sub === "versions") {
1991
2638
  const workflowRef = rest[0];
@@ -2026,48 +2673,47 @@ export async function run(flags) {
2026
2673
  return printResult(await inboxFlow(json, defaultDeps));
2027
2674
  }
2028
2675
  if (sub === "gate") {
2676
+ console.error(RETIRED_GATE_VERB_POINTER);
2677
+ process.exit(1);
2678
+ }
2679
+ if (sub === "checkpoint") {
2029
2680
  const runId = rest[0];
2030
2681
  if (!runId) {
2031
- console.error("Error: workflow gate requires <runId>.");
2032
- console.log("Usage: exodus workflow gate <runId> [pick <n,..> | edit <n> | push \"msg\" | approve | reject]");
2682
+ console.error("Error: workflow checkpoint requires <runId>.");
2683
+ console.log('Usage: exodus workflow checkpoint <runId> [show | edit <n> | approve [--reject <n,..>] | retry | cancel]');
2033
2684
  process.exit(1);
2034
2685
  }
2035
2686
  const action = rest[1];
2036
- if (!action)
2037
- return printResult(await gateShowFlow(runId, { json }, defaultDeps));
2038
- if (action === "pick") {
2039
- const numbers = parsePickNumbers(rest[2]);
2040
- if (!numbers) {
2041
- console.error(`Error: gate pick needs comma-separated 1-based numbers (e.g. \`pick 3,7\`).`);
2042
- process.exit(1);
2043
- }
2044
- return printResult(await gatePickFlow(runId, numbers, { json }, defaultDeps));
2687
+ if (!action || action === "show") {
2688
+ return printResult(await checkpointShowFlow(runId, { json }, defaultDeps));
2045
2689
  }
2046
2690
  if (action === "edit") {
2047
2691
  const n = parseTriggerIndex(rest[2]);
2048
2692
  if (n === undefined) {
2049
- console.error("Error: gate edit needs a 1-based candidate number <n>.");
2050
- console.log(`Usage: exodus workflow gate <runId> edit <n> [--text "..." | --file <path> | (stdin)]`);
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)]`);
2051
2695
  process.exit(1);
2052
2696
  }
2053
2697
  const stdin = await maybeReadStdin(flags);
2054
- return printResult(await gateEditFlow(runId, n, { text: flagString(flags, "text"), file: flagString(flags, "file"), stdin }, { json }, defaultDeps));
2055
- }
2056
- if (action === "push") {
2057
- const message = rest[2];
2058
- if (!message) {
2059
- console.error('Error: gate push needs a message (e.g. `push "make it punchier"`).');
2060
- process.exit(1);
2061
- }
2062
- return printResult(await gatePushFlow(runId, message, { json }, defaultDeps));
2698
+ return printResult(await checkpointEditFlow(runId, n, { text: flagString(flags, "text"), file: flagString(flags, "file"), stdin }, { json }, defaultDeps));
2063
2699
  }
2064
2700
  if (action === "approve") {
2065
- 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));
2066
2712
  }
2067
- if (action === "reject") {
2068
- 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));
2069
2715
  }
2070
- 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).`);
2071
2717
  process.exit(1);
2072
2718
  }
2073
2719
  if (sub === "repair") {