@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.
- 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/browse.js +2 -1
- package/dist/commands/creative.js +2 -2
- package/dist/commands/genesis.js +4 -4
- 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 +83 -17
- package/dist/commands/workflow.js +892 -246
- package/dist/lib/format.d.ts +2 -0
- package/dist/lib/format.js +21 -5
- package/dist/lib/intel-format.js +2 -1
- 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
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,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
|
|
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-
|
|
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
|
-
:
|
|
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
|
|
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] =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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({
|
|
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 (
|
|
1030
|
-
lines.push(formatWorkflowRun(
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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-
|
|
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
|
|
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
|
|
2137
|
+
return "parked at the cost gate (legacy)";
|
|
1534
2138
|
}
|
|
1535
2139
|
}
|
|
1536
2140
|
const PARK_LABEL = {
|
|
1537
|
-
taste: "a
|
|
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
|
-
|
|
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[
|
|
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
|
|
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
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
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
|
|
2227
|
+
function checkpointRangeError(num, count, json) {
|
|
1580
2228
|
const range = count > 0 ? ` (valid: 1–${count})` : "";
|
|
1581
|
-
return errLine(`
|
|
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
|
|
1584
|
-
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);
|
|
1585
2233
|
if (!pf.ok)
|
|
1586
2234
|
return pf.result;
|
|
1587
|
-
const
|
|
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
|
-
|
|
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
|
-
`
|
|
2275
|
+
`Checkpoint — ${pf.run.workflowName}`,
|
|
1604
2276
|
`runId: ${runId}`,
|
|
1605
|
-
`
|
|
2277
|
+
`step: ${stepLabel}`,
|
|
1606
2278
|
"",
|
|
1607
|
-
|
|
1608
|
-
formatCandidates(cands),
|
|
2279
|
+
"This step is done and the run is holding here until you say go.",
|
|
1609
2280
|
"",
|
|
1610
|
-
|
|
1611
|
-
`
|
|
1612
|
-
|
|
1613
|
-
|
|
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
|
|
1618
|
-
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);
|
|
1619
2327
|
if (!pf.ok)
|
|
1620
2328
|
return pf.result;
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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
|
|
1632
|
-
const res = await deps.post(
|
|
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
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
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, "
|
|
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
|
|
1669
|
-
const
|
|
1670
|
-
if (!
|
|
1671
|
-
return
|
|
1672
|
-
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, {
|
|
1673
2378
|
runId,
|
|
1674
2379
|
nodeId: pf.run.pausedNodeId,
|
|
1675
|
-
outputIndex:
|
|
2380
|
+
outputIndex: target.outputIndex,
|
|
1676
2381
|
text,
|
|
1677
2382
|
});
|
|
1678
2383
|
if (!res.ok)
|
|
1679
|
-
return triggerErrorResult(res, "workflow
|
|
1680
|
-
return okLine(`Edited
|
|
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
|
|
1739
|
-
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);
|
|
1740
2389
|
if (!pf.ok)
|
|
1741
2390
|
return pf.result;
|
|
1742
|
-
const res = await deps.post(
|
|
2391
|
+
const res = await deps.post(CHECKPOINT_RETRY_PATH, { runId });
|
|
1743
2392
|
if (!res.ok)
|
|
1744
|
-
return triggerErrorResult(res, "workflow
|
|
2393
|
+
return triggerErrorResult(res, "workflow checkpoint retry", opts.json);
|
|
1745
2394
|
const triggerRunId = res.data.triggerRunId;
|
|
1746
|
-
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
|
+
});
|
|
1747
2399
|
}
|
|
1748
|
-
export async function
|
|
1749
|
-
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);
|
|
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
|
|
1758
|
-
return okLine(`
|
|
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, {
|
|
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 =
|
|
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
|
|
2032
|
-
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]');
|
|
2033
2684
|
process.exit(1);
|
|
2034
2685
|
}
|
|
2035
2686
|
const action = rest[1];
|
|
2036
|
-
if (!action)
|
|
2037
|
-
return printResult(await
|
|
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:
|
|
2050
|
-
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)]`);
|
|
2051
2695
|
process.exit(1);
|
|
2052
2696
|
}
|
|
2053
2697
|
const stdin = await maybeReadStdin(flags);
|
|
2054
|
-
return printResult(await
|
|
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
|
|
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 === "
|
|
2068
|
-
return printResult(await
|
|
2713
|
+
if (action === "cancel") {
|
|
2714
|
+
return printResult(await checkpointCancelFlow(runId, { reason: flagString(flags, "reason"), json }, defaultDeps));
|
|
2069
2715
|
}
|
|
2070
|
-
console.error(`Error: unknown
|
|
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") {
|