@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.
- package/README.md +51 -0
- package/assets/docs/CLAUDE.md +1 -1
- package/assets/skills/exodus/SKILL.md +1 -1
- package/assets/skills/exodus-workflow/SKILL.md +221 -31
- package/dist/commands/bank.d.ts +1 -0
- package/dist/commands/bank.js +3 -3
- package/dist/commands/browse.js +2 -1
- package/dist/commands/creative.js +2 -2
- package/dist/commands/genesis.js +4 -4
- package/dist/commands/keys.js +1 -0
- package/dist/commands/primer.js +2 -1
- package/dist/commands/status.js +4 -4
- package/dist/commands/swipe.js +4 -4
- package/dist/commands/template.js +2 -2
- package/dist/commands/winners.js +3 -3
- package/dist/commands/workflow.d.ts +89 -17
- package/dist/commands/workflow.js +994 -265
- package/dist/lib/format.d.ts +7 -0
- package/dist/lib/format.js +52 -5
- package/dist/lib/intel-format.js +2 -1
- package/dist/lib/keys-sync.js +1 -0
- package/dist/lib/poll.js +7 -4
- package/dist/lib/runStatus.d.ts +22 -0
- package/dist/lib/runStatus.js +139 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
|
30
|
-
exodus workflow
|
|
31
|
-
exodus workflow
|
|
32
|
-
exodus workflow
|
|
33
|
-
exodus workflow
|
|
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
|
-
(
|
|
73
|
-
--file <path> (
|
|
74
|
-
--reason "..." (
|
|
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 --
|
|
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
|
|
105
|
-
exodus workflow
|
|
106
|
-
exodus workflow
|
|
107
|
-
exodus workflow
|
|
108
|
-
exodus workflow
|
|
109
|
-
exodus workflow
|
|
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,
|
|
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
|
|
145
|
-
is resolved with the "
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
"
|
|
152
|
-
|
|
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
|
|
177
|
-
const
|
|
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
|
-
: [
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1414
|
+
throw new WorkflowResolveError(res.status, res.data, formatApiError(res));
|
|
821
1415
|
const workflows = res.data.workflows ?? [];
|
|
822
|
-
const
|
|
823
|
-
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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({
|
|
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 (
|
|
955
|
-
lines.push(formatWorkflowRun(
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2024
|
+
return formatApiError({ ok: false, status: res.status, data: snippet || "(empty response)" });
|
|
1339
2025
|
}
|
|
1340
|
-
return
|
|
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
|
-
|
|
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-
|
|
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
|
|
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
|
|
2137
|
+
return "parked at the cost gate (legacy)";
|
|
1462
2138
|
}
|
|
1463
2139
|
}
|
|
1464
2140
|
const PARK_LABEL = {
|
|
1465
|
-
taste: "a
|
|
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
|
-
|
|
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[
|
|
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
|
|
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
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
|
2227
|
+
function checkpointRangeError(num, count, json) {
|
|
1508
2228
|
const range = count > 0 ? ` (valid: 1–${count})` : "";
|
|
1509
|
-
return errLine(`
|
|
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
|
|
1512
|
-
const pf = await preflightPark(runId, "
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
2275
|
+
`Checkpoint — ${pf.run.workflowName}`,
|
|
1532
2276
|
`runId: ${runId}`,
|
|
1533
|
-
`
|
|
2277
|
+
`step: ${stepLabel}`,
|
|
1534
2278
|
"",
|
|
1535
|
-
|
|
1536
|
-
formatCandidates(cands),
|
|
2279
|
+
"This step is done and the run is holding here until you say go.",
|
|
1537
2280
|
"",
|
|
1538
|
-
|
|
1539
|
-
`
|
|
1540
|
-
|
|
1541
|
-
|
|
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
|
|
1546
|
-
const
|
|
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
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
|
1560
|
-
const res = await deps.post(
|
|
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
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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, "
|
|
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
|
|
1597
|
-
const
|
|
1598
|
-
if (!
|
|
1599
|
-
return
|
|
1600
|
-
const res = await deps.post(
|
|
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:
|
|
2380
|
+
outputIndex: target.outputIndex,
|
|
1604
2381
|
text,
|
|
1605
2382
|
});
|
|
1606
2383
|
if (!res.ok)
|
|
1607
|
-
return triggerErrorResult(res, "workflow
|
|
1608
|
-
return okLine(`Edited
|
|
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
|
|
1656
|
-
const pf = await preflightPark(runId, "
|
|
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(
|
|
2391
|
+
const res = await deps.post(CHECKPOINT_RETRY_PATH, { runId });
|
|
1660
2392
|
if (!res.ok)
|
|
1661
|
-
return triggerErrorResult(res, "workflow
|
|
2393
|
+
return triggerErrorResult(res, "workflow checkpoint retry", opts.json);
|
|
1662
2394
|
const triggerRunId = res.data.triggerRunId;
|
|
1663
|
-
return resumeAndMaybeWait(runId, triggerRunId, ["
|
|
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
|
|
1666
|
-
const pf = await preflightPark(runId, "taste", "workflow
|
|
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
|
|
1675
|
-
return okLine(`
|
|
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, {
|
|
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 =
|
|
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
|
|
1949
|
-
console.log(
|
|
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
|
|
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:
|
|
1967
|
-
console.log(`Usage: exodus workflow
|
|
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
|
|
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
|
|
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 === "
|
|
1985
|
-
return printResult(await
|
|
2713
|
+
if (action === "cancel") {
|
|
2714
|
+
return printResult(await checkpointCancelFlow(runId, { reason: flagString(flags, "reason"), json }, defaultDeps));
|
|
1986
2715
|
}
|
|
1987
|
-
console.error(`Error: unknown
|
|
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") {
|