@aicopycoders/exodus 2026.7.200-beta.8 → 2026.8.1100
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 +3 -0
- package/assets/docs/PIPELINES.md +50 -0
- package/assets/skills/exodus/SKILL.md +6 -2
- package/assets/skills/exodus-meme/SKILL.md +44 -28
- package/assets/skills/exodus-winners/SKILL.md +239 -0
- package/assets/skills/exodus-workflow/SKILL.md +464 -63
- package/dist/bin/exodus.js +3 -0
- package/dist/commands/bank.d.ts +88 -0
- package/dist/commands/bank.js +346 -0
- package/dist/commands/browse.js +2 -1
- package/dist/commands/creative.js +2 -2
- package/dist/commands/genesis.js +16 -4
- package/dist/commands/keys.js +1 -0
- package/dist/commands/meme.js +9 -6
- package/dist/commands/primer.js +2 -1
- package/dist/commands/session.d.ts +55 -0
- package/dist/commands/session.js +202 -0
- package/dist/commands/status.js +4 -4
- package/dist/commands/swipe.js +4 -4
- package/dist/commands/template.js +2 -2
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +20 -0
- package/dist/commands/winners.js +13 -3
- package/dist/commands/workflow.d.ts +293 -8
- package/dist/commands/workflow.js +2396 -90
- package/dist/lib/client.d.ts +8 -1
- package/dist/lib/client.js +30 -6
- 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/route-support.d.ts +4 -0
- package/dist/lib/route-support.js +22 -0
- package/dist/lib/runStatus.d.ts +22 -0
- package/dist/lib/runStatus.js +139 -0
- package/dist/lib/workflowText.d.ts +3 -0
- package/dist/lib/workflowText.js +113 -0
- package/package.json +5 -2
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import nodePath from "node:path";
|
|
4
|
+
import { apiGet, apiGetText, apiPost, apiPostDashboard, getDashboardUrl, } from "../lib/client.js";
|
|
5
|
+
import { formatApiError } from "../lib/format.js";
|
|
4
6
|
import { pollUntilDone } from "../lib/poll.js";
|
|
7
|
+
import { normalizeRunStatus, runStatusLabel, storedWorkflowStatusForms, TERMINAL_RUN_STATUSES, } from "../lib/runStatus.js";
|
|
8
|
+
import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
|
|
9
|
+
import { missingRouteLine } from "../lib/route-support.js";
|
|
10
|
+
import { getChannel } from "../lib/channel.js";
|
|
5
11
|
export const helpText = `
|
|
6
12
|
exodus workflow — List, describe, run, inspect, import, and export saved workflows
|
|
7
13
|
|
|
@@ -9,10 +15,27 @@ Usage:
|
|
|
9
15
|
exodus workflow list [--json]
|
|
10
16
|
exodus workflow describe <workflowId|name> [--json]
|
|
11
17
|
exodus workflow bots [--category <cat>] [--slug <slug>] [--json]
|
|
12
|
-
exodus workflow
|
|
13
|
-
exodus workflow
|
|
14
|
-
exodus workflow
|
|
18
|
+
exodus workflow templates [list] [--json]
|
|
19
|
+
exodus workflow templates export <key> [--out <file>] [--json]
|
|
20
|
+
exodus workflow schema [--kind <kind>] [--face <face>] [--json]
|
|
21
|
+
exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=<path> ...] [--terminal <nodeId> ...] [--rig-overrides <json|@file>] [--auto-approve] [--wait] [--out <dir>] [--json]
|
|
22
|
+
exodus workflow status [--id <runId>] [--out <dir>] [--json]
|
|
23
|
+
exodus workflow versions <workflowId|name> [--json]
|
|
24
|
+
exodus workflow export <workflowId|name> [--version <n>] [--out <file>] [--json]
|
|
25
|
+
exodus workflow validate <file> [--update <workflowId>] [--json]
|
|
15
26
|
exodus workflow import <file> [--update <workflowId>] [--dry-run] [--json]
|
|
27
|
+
exodus workflow triggers <workflowId|name> [--json]
|
|
28
|
+
exodus workflow triggers <workflowId|name> enable <n> [--json]
|
|
29
|
+
exodus workflow triggers <workflowId|name> disable <n> [--json]
|
|
30
|
+
exodus workflow triggers <workflowId|name> fire [<n>] [--text "..."] [--rig-overrides <json|@file>] [--wait] [--json]
|
|
31
|
+
exodus workflow inbox [--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 [--note "..."] [--wait] [--json]
|
|
36
|
+
exodus workflow checkpoint <runId> cancel [--reason "..."] [--json]
|
|
37
|
+
exodus workflow repair <runId> retry|skip|kill [--wait] [--json]
|
|
38
|
+
exodus workflow answer <runId> --slot key=value [--slot key=value ...] [--json]
|
|
16
39
|
|
|
17
40
|
Flags:
|
|
18
41
|
--json Machine-readable JSON output
|
|
@@ -22,31 +45,211 @@ Flags:
|
|
|
22
45
|
--input key=@path loads the value from a file (path is
|
|
23
46
|
resolved from the current directory); --input key=@@text
|
|
24
47
|
keeps a leading "@" as a literal character.
|
|
25
|
-
|
|
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. An http(s) URL is accepted too
|
|
53
|
+
(--input hero=https://example.com/hero.png) — the server
|
|
54
|
+
fetches and registers it at launch. Pass an asset id
|
|
55
|
+
from an earlier upload to reuse it. Accepted files:
|
|
56
|
+
image PNG, JPEG, WebP, GIF (≤15MB) · video MP4, MOV,
|
|
57
|
+
WebM (≤200MB) · audio MP3, M4A, WAV, OGG (≤50MB) ·
|
|
58
|
+
document PDF, TXT, MD, DOC, DOCX (≤25MB).
|
|
59
|
+
MULTI-CHOICE fields (describe tags them "multi-select")
|
|
60
|
+
take several picks in ONE value, comma-separated:
|
|
61
|
+
--input tone=casual,punchy.
|
|
62
|
+
--fill <name> (run) Launch from a saved fill — a named set of inputs
|
|
63
|
+
saved on THIS brand's copy of the workflow. Its values
|
|
64
|
+
become the run's inputs; any --input you also pass wins
|
|
65
|
+
for that one key, so a fill is a starting point, not a
|
|
66
|
+
lock. Names are exact (case-sensitive); an unknown one
|
|
67
|
+
is rejected before the run starts. Fills are created and
|
|
68
|
+
named in the dashboard's run dialog.
|
|
69
|
+
--terminal <nodeId> (run) Repeatable. Scope the run to the upstream closure
|
|
70
|
+
of these end node(s) — only nodes feeding a picked
|
|
71
|
+
terminal execute; the rest are recorded out-of-scope.
|
|
72
|
+
Omit to run the whole graph.
|
|
73
|
+
--rig-overrides <json> (run, triggers fire) Change what an Image Rig box fires
|
|
74
|
+
for THIS ONE run, without editing the saved workflow —
|
|
75
|
+
a different number of images, a different meme format,
|
|
76
|
+
a different model. Takes a JSON object keyed by the
|
|
77
|
+
box's node id, or @path to a .json file holding one:
|
|
78
|
+
--rig-overrides '{"rig_1":{"lines":{"line_1":{"count":3}}}}'
|
|
79
|
+
Add "confirmLargeRun": true beside "lines" to allow a
|
|
80
|
+
run over the brand's image safety cap — the same
|
|
81
|
+
acknowledgement the tick-box in the app asks for. A box
|
|
82
|
+
or line name the workflow doesn't have is refused
|
|
83
|
+
before anything runs, and the error names it. On
|
|
84
|
+
"triggers fire" it REPLACES the schedule's own
|
|
85
|
+
overrides for that one test fire.
|
|
86
|
+
--auto-approve (run) Deliberately unattended launch: every Checkpoint
|
|
87
|
+
box this run stops at is approved automatically, with
|
|
88
|
+
whatever it was holding left exactly as it is, and the
|
|
89
|
+
run record marks each stop as auto-approved so you can
|
|
90
|
+
see nobody looked. Default is to park and wait for a
|
|
91
|
+
person's verdict. This is a choice you make for ONE
|
|
92
|
+
launch — it is never a setting on the workflow itself.
|
|
93
|
+
--wait Poll until the workflow run reaches a terminal status.
|
|
94
|
+
(checkpoint retry) Re-runs the step feeding the
|
|
95
|
+
Checkpoint box and waits until its fresh output is
|
|
96
|
+
ready and the run parks again — that IS its finish
|
|
97
|
+
line; a redo never runs the workflow to completion.
|
|
26
98
|
--id <runId> Workflow run id for status detail
|
|
27
|
-
--out <file> Write export
|
|
28
|
-
--
|
|
29
|
-
of
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
99
|
+
--out <file> Write the export to a file instead of stdout
|
|
100
|
+
--out <dir> (run --wait / status --id) Save every delivered output
|
|
101
|
+
of the finished run into this directory (created if
|
|
102
|
+
missing) and print the paths — text as .md, storyboards
|
|
103
|
+
and frame sets as .json, images/video/audio downloaded.
|
|
104
|
+
Unfulfilled slots are reported, never written.
|
|
105
|
+
--version <n> (export) Export a saved historical version instead of the
|
|
106
|
+
current head. <n> is the real 1-based version id from
|
|
107
|
+
"workflow versions" (a positive integer). A version
|
|
108
|
+
export intentionally carries NO triggers/description —
|
|
109
|
+
those aren't versioned, so a rollback leaves them
|
|
110
|
+
unchanged; head exports remain the only place triggers
|
|
111
|
+
appear.
|
|
112
|
+
--json (export) Emit the legacy JSON contract body instead of
|
|
113
|
+
the default canonical YAML (escape hatch for tools that
|
|
114
|
+
still parse the old JSON export byte-for-byte)
|
|
115
|
+
--update <workflowId> (import/validate) Update this existing workflow in place
|
|
116
|
+
instead of creating a new one. Sends the contract's
|
|
117
|
+
updatedAt as an optimistic-concurrency guard — if it
|
|
118
|
+
409s, re-export the workflow, reapply edits, and retry.
|
|
119
|
+
--dry-run Preview import (validate + resolve refs) without writing.
|
|
120
|
+
Same server-side check as "workflow validate <file>" —
|
|
121
|
+
use validate as the standalone front door.
|
|
122
|
+
--text "..." (triggers fire) The input an EVENT trigger's run carries.
|
|
123
|
+
Required for event triggers; rejected for cron triggers.
|
|
124
|
+
(checkpoint edit) The replacement copy for one output.
|
|
125
|
+
--file <path> (checkpoint edit) Load the replacement copy from a file.
|
|
126
|
+
--reason "..." (checkpoint cancel) Optional reason recorded on the
|
|
127
|
+
cancel.
|
|
128
|
+
--note "..." (checkpoint retry) Optional correction for the redo
|
|
129
|
+
("make the hook punchier"). Steps that call a model
|
|
130
|
+
follow it on the re-run; deterministic steps only
|
|
131
|
+
record it on the run's review trail.
|
|
132
|
+
--reject <n[,n..]> (checkpoint approve) Only for a Checkpoint box that is
|
|
133
|
+
reviewing a Splitter fan-out item by item. Drops those
|
|
134
|
+
ITEM numbers (the "Item N of M" headings in the show
|
|
135
|
+
listing, 1-based) and approves the rest. Rejecting is
|
|
136
|
+
filtering, not failing — the run carries on with the
|
|
137
|
+
survivors. Rejecting every item is refused: cancel
|
|
138
|
+
instead.
|
|
139
|
+
--slot key=value (answer) Repeatable. One answer per pending slot.
|
|
140
|
+
--kind <kind> (schema) Print just one node kind's ports + config rules
|
|
141
|
+
--face <face> (schema) Print just one transform face's spec
|
|
33
142
|
|
|
34
143
|
Examples:
|
|
35
144
|
exodus workflow describe "Launch Flow"
|
|
36
145
|
exodus workflow bots
|
|
37
146
|
exodus workflow bots --category writing
|
|
38
147
|
exodus workflow bots --slug new-hook-bot
|
|
148
|
+
exodus workflow templates
|
|
149
|
+
exodus workflow templates export complete-ad-set --out my.yaml
|
|
150
|
+
exodus workflow schema
|
|
151
|
+
exodus workflow schema --kind transform
|
|
152
|
+
exodus workflow schema --kind splitter
|
|
153
|
+
exodus workflow schema --kind collector
|
|
154
|
+
exodus workflow schema --kind checkpoint
|
|
155
|
+
exodus workflow schema --face collector
|
|
39
156
|
exodus workflow run "Launch Flow" --input brief="new offer" --wait
|
|
40
157
|
exodus workflow run "Launch Flow" --input brief=@brief.txt
|
|
158
|
+
exodus workflow run "Product Shots" --input hero=./photos/hero.png --wait
|
|
159
|
+
exodus workflow run "Product Shots" --input hero=https://example.com/hero.png
|
|
160
|
+
exodus workflow run "Launch Flow" --fill "Weekly promo" --wait
|
|
161
|
+
exodus workflow run "Launch Flow" --fill "Weekly promo" --input brief="new offer"
|
|
162
|
+
exodus workflow run "Launch Flow" --terminal bot-3 --terminal image-2
|
|
163
|
+
exodus workflow run "Launch Flow" --auto-approve --wait
|
|
164
|
+
exodus workflow run "Product Shots" --rig-overrides '{"rig_1":{"lines":{"line_1":{"count":3}}}}'
|
|
165
|
+
exodus workflow run "Product Shots" --rig-overrides @rig.json --wait
|
|
166
|
+
exodus workflow run "Launch Flow" --wait --out ./deliverables
|
|
41
167
|
exodus workflow status --id wr_123
|
|
42
|
-
exodus workflow
|
|
43
|
-
exodus workflow
|
|
168
|
+
exodus workflow status --id wr_123 --out ./deliverables
|
|
169
|
+
exodus workflow versions "Launch Flow"
|
|
170
|
+
exodus workflow export "Launch Flow" --out workflow.yaml
|
|
171
|
+
exodus workflow export "Launch Flow" --version 3 --out v3.yaml
|
|
172
|
+
exodus workflow export "Launch Flow" --json --out workflow.json
|
|
173
|
+
exodus workflow validate my.yaml
|
|
174
|
+
exodus workflow import workflow.yaml --dry-run
|
|
44
175
|
exodus workflow import workflow.json --update wf_123
|
|
176
|
+
exodus workflow triggers "Winner Flywheel"
|
|
177
|
+
exodus workflow triggers "Winner Flywheel" enable 1
|
|
178
|
+
exodus workflow triggers "Winner Flywheel" fire 1 --text "new offer" --wait
|
|
179
|
+
exodus workflow inbox
|
|
180
|
+
exodus workflow checkpoint wr_123
|
|
181
|
+
exodus workflow checkpoint wr_123 edit 1 --text "tighter opener"
|
|
182
|
+
exodus workflow checkpoint wr_123 approve --wait
|
|
183
|
+
exodus workflow checkpoint wr_123 approve --reject 2,5 --wait
|
|
184
|
+
exodus workflow checkpoint wr_123 retry --note "make the hook punchier" --wait
|
|
185
|
+
exodus workflow checkpoint wr_123 cancel --reason "wrong direction"
|
|
186
|
+
exodus workflow repair wr_123 retry --wait
|
|
187
|
+
exodus workflow answer wr_123 --slot tone=casual --slot length=short
|
|
188
|
+
|
|
189
|
+
# Diff two saved versions:
|
|
190
|
+
exodus workflow export X --version 3 --out v3.yaml && exodus workflow export X --version 5 --out v5.yaml && diff v3.yaml v5.yaml
|
|
191
|
+
# Roll a workflow back to an earlier version:
|
|
192
|
+
exodus workflow export X --version 3 --out v3.yaml && exodus workflow import v3.yaml --update <id>
|
|
45
193
|
|
|
46
194
|
Notes:
|
|
195
|
+
workflow describe prints the ENTRY CONTRACT — every input the workflow asks
|
|
196
|
+
for, whether it's required or optional, the shape it accepts (a dropdown and
|
|
197
|
+
its choices, a number, a true/false toggle; a plain text box shows no tag),
|
|
198
|
+
and the author's help text. Supply those keys with --input; a value the
|
|
199
|
+
contract can't accept is rejected before the run starts, naming the field.
|
|
200
|
+
It also prints the EXIT contract under "Delivers" — the named results a
|
|
201
|
+
finished run hands back, in the author's order: the slot's label, its key (the
|
|
202
|
+
name --out files and a webhook key off), what kind of thing it is (text /
|
|
203
|
+
structured data / file / set of files), and the author's note. A workflow with
|
|
204
|
+
no Output node says so plainly ("Delivers: nothing — no Output node."), and
|
|
205
|
+
"workflow run" repeats that as a warning before it spends the run. Against a
|
|
206
|
+
backend too old to report deliveries the section is omitted entirely.
|
|
207
|
+
"workflow run --fill <name>" launches from a saved fill instead of typing the
|
|
208
|
+
inputs again — the fill's values fill the contract, and any --input you pass
|
|
209
|
+
alongside it overrides just that key.
|
|
210
|
+
Cold-start is a template, not a blank file: "workflow templates" lists the
|
|
211
|
+
starters (incl. Winner Flywheel), "workflow templates export <key> --out f.yaml"
|
|
212
|
+
writes the server-rendered YAML verbatim — edit it, then "workflow import f.yaml".
|
|
213
|
+
"workflow schema" prints the LIVE graph vocabulary (node kinds, ports, config
|
|
214
|
+
rules, transform faces, collector policy, wiring rules) from the backend you're
|
|
215
|
+
deployed against, so what you author matches what will validate; --kind/--face
|
|
216
|
+
narrow it, --json is the machine payload. "workflow validate <file>" checks a
|
|
217
|
+
file against the live backend (it IS import --dry-run under its own door).
|
|
218
|
+
"workflow run --rig-overrides" re-aims an Image Rig box for ONE run without
|
|
219
|
+
touching the saved workflow: how many images a line fires, which meme format,
|
|
220
|
+
which model. Node ids and line keys come from "workflow export" (or the box's
|
|
221
|
+
panel in the app); anything you name that isn't there stops the launch and the
|
|
222
|
+
message says which key was wrong. A trigger can carry the same payload in its
|
|
223
|
+
YAML ("imageRigOverrides:" beside its schedule), so a Monday schedule and a
|
|
224
|
+
Friday one can fire the same workflow at different sizes.
|
|
225
|
+
workflow triggers are addressed by 1-based position — a trigger carries no id,
|
|
226
|
+
so the CLI reads the live list from the export contract and sends a fingerprint
|
|
227
|
+
of the trigger's fields as the guard; a concurrent edit fails loud rather than
|
|
228
|
+
flipping the wrong trigger. Add or remove triggers by editing the YAML export.
|
|
229
|
+
workflow export writes canonical YAML by default (human-diffable, key order
|
|
230
|
+
fixed so equal workflows dump byte-identically); pass --json for the legacy
|
|
231
|
+
JSON contract body. workflow import accepts either a YAML export or a legacy
|
|
232
|
+
JSON file — same downstream, so YAML files work against the deployed API.
|
|
233
|
+
workflow versions lists a workflow's saved history newest-first (up to 50);
|
|
234
|
+
version numbers are real 1-based ids you pass to export --version. A version
|
|
235
|
+
export intentionally carries NO triggers/description (they aren't versioned),
|
|
236
|
+
so rolling back with import --update leaves them unchanged — head exports
|
|
237
|
+
remain the only place triggers appear.
|
|
47
238
|
workflow bots --json emits the FULL catalog response verbatim (--category /
|
|
48
239
|
--slug filters are ignored in that mode). workflow bots --slug <slug> --json
|
|
49
240
|
emits just that one bot's catalog JSON.
|
|
241
|
+
workflow inbox lists every run parked waiting on you, badged by park kind
|
|
242
|
+
(checkpoint/repair/slots/gate/legacy) and how it started (bg / trig:<event>).
|
|
243
|
+
A run parked at a Checkpoint box is resolved with the "checkpoint" verbs: show
|
|
244
|
+
what is waiting there, edit one output in place (edit 1 --text ...), approve
|
|
245
|
+
(resume), retry (re-run the step feeding the box), or cancel. A Checkpoint is
|
|
246
|
+
its own box on the canvas, sitting on a wire between two steps, so the diagram
|
|
247
|
+
shows exactly where a run will stop. A require-all collector that stalled
|
|
248
|
+
on a dead input is a "repair" park — retry it, skip the dead input, or kill the
|
|
249
|
+
run. A nested sub-workflow waiting on inputs is a "slots" park — answer it with
|
|
250
|
+
repeatable --slot key=value flags (run "answer" with no --slot to list the slot
|
|
251
|
+
ids it wants). The "gate" verbs retired in 2.0 along with the Gate node — the
|
|
252
|
+
command now prints a pointer at "checkpoint" and exits 1.
|
|
50
253
|
`.trim();
|
|
51
254
|
const LIST_PATH = "/api/v2/workflows";
|
|
52
255
|
const RUN_PATH = "/api/v2/workflows/run";
|
|
@@ -54,21 +257,113 @@ const STATUS_PATH = "/api/v2/workflow";
|
|
|
54
257
|
const EXPORT_PATH = "/api/v2/workflows/export";
|
|
55
258
|
const IMPORT_PATH = "/api/v2/workflows/import";
|
|
56
259
|
const DESCRIBE_PATH = "/api/v2/workflows/describe";
|
|
260
|
+
const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
|
|
261
|
+
const ASSETS_PATH = "/api/v2/workflows/assets";
|
|
57
262
|
const CATALOG_PATH = "/api/v2/workflows/catalog";
|
|
58
|
-
const
|
|
263
|
+
const TRIGGERS_SET_ENABLED_PATH = "/api/v2/workflows/triggers/set-enabled";
|
|
264
|
+
const TRIGGERS_FIRE_PATH = "/api/v2/workflows/triggers/fire";
|
|
265
|
+
const TEMPLATES_PATH = "/api/v2/workflows/templates";
|
|
266
|
+
const SCHEMA_PATH = "/api/v2/workflows/schema";
|
|
267
|
+
const VERSIONS_PATH = "/api/v2/workflows/versions";
|
|
268
|
+
const VERSIONS_CAP = 50;
|
|
269
|
+
const INBOX_PATH = "/api/v2/workflow/inbox";
|
|
270
|
+
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
271
|
+
const CANCEL_PATH = "/api/v2/workflow/cancel";
|
|
272
|
+
const ANSWER_PATH = "/api/v2/workflow/answer";
|
|
273
|
+
const REPAIR_RETRY_PATH = "/api/v2/workflow/repair/retry";
|
|
274
|
+
const REPAIR_SKIP_PATH = "/api/v2/workflow/repair/skip";
|
|
275
|
+
const CHECKPOINT_RETRY_PATH = "/api/v2/workflow/checkpoint/retry";
|
|
276
|
+
const CHECKPOINT_EDIT_PATH = "/api/v2/workflow/checkpoint/edit";
|
|
277
|
+
const CHECKPOINT_RESOLVE_ITEMS_PATH = "/api/v2/workflow/checkpoint/resolve-items";
|
|
278
|
+
const RUN_PAGE_PREFIX = "/runs/";
|
|
279
|
+
const VALUE_FLAGS = new Set([
|
|
280
|
+
"id",
|
|
281
|
+
"input",
|
|
282
|
+
"out",
|
|
283
|
+
"category",
|
|
284
|
+
"slug",
|
|
285
|
+
"update",
|
|
286
|
+
"terminal",
|
|
287
|
+
"fill",
|
|
288
|
+
"text",
|
|
289
|
+
"kind",
|
|
290
|
+
"face",
|
|
291
|
+
"version",
|
|
292
|
+
"file",
|
|
293
|
+
"reason",
|
|
294
|
+
"slot",
|
|
295
|
+
"reject",
|
|
296
|
+
"rig-overrides",
|
|
297
|
+
"note",
|
|
298
|
+
]);
|
|
299
|
+
function defaultMkdirp(dir) {
|
|
300
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
301
|
+
}
|
|
302
|
+
async function defaultDownloadToFile(url, path) {
|
|
303
|
+
const res = await fetch(url);
|
|
304
|
+
if (!res.ok)
|
|
305
|
+
throw new Error(`download failed with HTTP ${res.status}`);
|
|
306
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
307
|
+
fs.writeFileSync(path, buf);
|
|
308
|
+
}
|
|
59
309
|
const defaultDeps = {
|
|
60
310
|
get: (path) => apiGet(path),
|
|
311
|
+
getText: (path) => apiGetText(path),
|
|
61
312
|
post: (path, body) => apiPost(path, body),
|
|
62
313
|
readFile: (path) => fs.readFileSync(path, "utf-8"),
|
|
63
314
|
writeFile: (path, text) => fs.writeFileSync(path, text, "utf-8"),
|
|
64
315
|
poll: (opts) => pollUntilDone(opts),
|
|
316
|
+
mkdirp: defaultMkdirp,
|
|
317
|
+
downloadToFile: defaultDownloadToFile,
|
|
318
|
+
postDashboard: (path, body, opts) => apiPostDashboard(path, body, opts),
|
|
319
|
+
dashboardUrl: getDashboardUrl(),
|
|
320
|
+
statFile: (filePath) => {
|
|
321
|
+
try {
|
|
322
|
+
const stat = fs.statSync(filePath);
|
|
323
|
+
return stat.isFile() ? { size: stat.size } : null;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
readFileBytes: (filePath) => fs.readFileSync(filePath),
|
|
330
|
+
uploadBytes: async (uploadUrl, contentType, bytes) => {
|
|
331
|
+
const res = await fetch(uploadUrl, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: { "Content-Type": contentType },
|
|
334
|
+
body: new Blob([bytes]),
|
|
335
|
+
});
|
|
336
|
+
if (!res.ok) {
|
|
337
|
+
const body = await res.text().catch(() => "");
|
|
338
|
+
return { ok: false, status: res.status, body };
|
|
339
|
+
}
|
|
340
|
+
const parsed = (await res.json().catch(() => ({})));
|
|
341
|
+
return { ok: true, status: res.status, storageId: parsed.storageId };
|
|
342
|
+
},
|
|
65
343
|
};
|
|
66
344
|
function asErrorResult(res, json) {
|
|
67
345
|
return {
|
|
68
346
|
code: 1,
|
|
69
347
|
lines: json
|
|
70
348
|
? [JSON.stringify({ ok: false, status: res.status, data: res.data })]
|
|
71
|
-
: [
|
|
349
|
+
: [formatApiError(res)],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function resolveIdErrorResult(e, json) {
|
|
353
|
+
if (e instanceof WorkflowResolveError) {
|
|
354
|
+
return {
|
|
355
|
+
code: 1,
|
|
356
|
+
lines: json
|
|
357
|
+
? [JSON.stringify({ ok: false, status: e.status, data: e.data })]
|
|
358
|
+
: [e.message],
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
362
|
+
return {
|
|
363
|
+
code: 1,
|
|
364
|
+
lines: json
|
|
365
|
+
? [JSON.stringify({ ok: false, status: 0, data: { error: { message } } })]
|
|
366
|
+
: [message],
|
|
72
367
|
};
|
|
73
368
|
}
|
|
74
369
|
function isRecord(value) {
|
|
@@ -113,12 +408,62 @@ function outputLines(output) {
|
|
|
113
408
|
if (output.type === "primer") {
|
|
114
409
|
return [` primer:${output.primerKind}: ${truncateText(output.text)}`];
|
|
115
410
|
}
|
|
116
|
-
|
|
411
|
+
if (output.type === "image") {
|
|
412
|
+
const url = output.imageUrl ?? output.storageId;
|
|
413
|
+
return url ? [` image: ${url}`] : [];
|
|
414
|
+
}
|
|
415
|
+
return [];
|
|
117
416
|
}
|
|
118
|
-
function progressLine(node) {
|
|
417
|
+
function progressLine(node, parked = false) {
|
|
119
418
|
const err = node.error ? ` — error: ${node.error}` : "";
|
|
419
|
+
if (parked) {
|
|
420
|
+
return ` ⏸ ${node.nodeId} (${node.kind}) awaiting approval`;
|
|
421
|
+
}
|
|
120
422
|
return ` ${statusIcon(node.status)} ${node.nodeId} (${node.kind}) ${node.status}${err}`;
|
|
121
423
|
}
|
|
424
|
+
function gateParkedNodeId(status, pauseReason, pausedNodeId) {
|
|
425
|
+
if (!status || normalizeRunStatus(status) !== "awaiting-approval")
|
|
426
|
+
return undefined;
|
|
427
|
+
if (pauseReason === "taste" || pauseReason === "checkpoint" || pauseReason === undefined) {
|
|
428
|
+
return pausedNodeId;
|
|
429
|
+
}
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
const RETIRED_GATE_VERB_POINTER = "The Gate node retired in 2.0 — runs now pause at a Checkpoint box on the canvas. " +
|
|
433
|
+
"Use: exodus workflow checkpoint <runId> [approve|edit|retry|cancel]";
|
|
434
|
+
export function formatPauseNotice(pauseReason, runId, dashboardUrl) {
|
|
435
|
+
if (!pauseReason) {
|
|
436
|
+
return [
|
|
437
|
+
" ⏸ paused at the cost gate — approve or edit the storyboard in the web app to continue.",
|
|
438
|
+
];
|
|
439
|
+
}
|
|
440
|
+
if (pauseReason === "call") {
|
|
441
|
+
return [" ⏸ waiting on a child workflow run — it resumes on its own."];
|
|
442
|
+
}
|
|
443
|
+
if (pauseReason === "checkpoint") {
|
|
444
|
+
return [
|
|
445
|
+
" ⏸ paused at a checkpoint — what's flowing through it is waiting on your approval.",
|
|
446
|
+
` Resolve here: exodus workflow checkpoint ${runId}`,
|
|
447
|
+
` Or in the app: ${dashboardUrl}${RUN_PAGE_PREFIX}${runId}`,
|
|
448
|
+
];
|
|
449
|
+
}
|
|
450
|
+
if (pauseReason === "taste") {
|
|
451
|
+
return [
|
|
452
|
+
" ⏸ paused at a Gate box, which retired in 2.0. Approvals now happen at a Checkpoint box.",
|
|
453
|
+
` Cancel it here: exodus workflow checkpoint ${runId} cancel`,
|
|
454
|
+
` Or in the app: ${dashboardUrl}${RUN_PAGE_PREFIX}${runId}`,
|
|
455
|
+
" Then run the workflow again.",
|
|
456
|
+
];
|
|
457
|
+
}
|
|
458
|
+
const resolveVerb = pauseReason === "repair"
|
|
459
|
+
? `exodus workflow repair ${runId} retry|skip|kill`
|
|
460
|
+
: `exodus workflow answer ${runId} --slot key=value`;
|
|
461
|
+
return [
|
|
462
|
+
" ⏸ paused for review — waiting on you.",
|
|
463
|
+
` Resolve here: ${resolveVerb}`,
|
|
464
|
+
` Or in the app: ${dashboardUrl}${RUN_PAGE_PREFIX}${runId}`,
|
|
465
|
+
];
|
|
466
|
+
}
|
|
122
467
|
function expandInputValue(key, value, readFile) {
|
|
123
468
|
if (value.startsWith("@@"))
|
|
124
469
|
return value.slice(1);
|
|
@@ -140,7 +485,14 @@ function expandInputValue(key, value, readFile) {
|
|
|
140
485
|
}
|
|
141
486
|
return value;
|
|
142
487
|
}
|
|
143
|
-
|
|
488
|
+
function normalizeMultiValue(value) {
|
|
489
|
+
return value
|
|
490
|
+
.split(",")
|
|
491
|
+
.map((p) => p.trim())
|
|
492
|
+
.filter((p) => p.length > 0)
|
|
493
|
+
.join(", ");
|
|
494
|
+
}
|
|
495
|
+
export function parseRawInputFlags(args) {
|
|
144
496
|
const inputs = {};
|
|
145
497
|
for (let i = 0; i < args.length; i++) {
|
|
146
498
|
const arg = args[i];
|
|
@@ -163,10 +515,425 @@ export function parseInputFlags(args, readFile) {
|
|
|
163
515
|
const key = raw.slice(0, eq).trim();
|
|
164
516
|
if (!key)
|
|
165
517
|
throw new Error(`--input must include a key (got "${raw}")`);
|
|
166
|
-
inputs[key] =
|
|
518
|
+
inputs[key] = raw.slice(eq + 1);
|
|
167
519
|
}
|
|
168
520
|
return inputs;
|
|
169
521
|
}
|
|
522
|
+
export function parseInputFlags(args, readFile) {
|
|
523
|
+
const inputs = parseRawInputFlags(args);
|
|
524
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
525
|
+
inputs[key] = expandInputValue(key, value, readFile);
|
|
526
|
+
}
|
|
527
|
+
return inputs;
|
|
528
|
+
}
|
|
529
|
+
const MB = 1024 * 1024;
|
|
530
|
+
export const ASSET_UPLOAD_POLICY = {
|
|
531
|
+
image: {
|
|
532
|
+
mimeByExtension: {
|
|
533
|
+
".png": "image/png",
|
|
534
|
+
".jpg": "image/jpeg",
|
|
535
|
+
".jpeg": "image/jpeg",
|
|
536
|
+
".webp": "image/webp",
|
|
537
|
+
".gif": "image/gif",
|
|
538
|
+
},
|
|
539
|
+
maxBytes: 15 * MB,
|
|
540
|
+
accepts: "PNG, JPEG, WebP, GIF",
|
|
541
|
+
},
|
|
542
|
+
video: {
|
|
543
|
+
mimeByExtension: {
|
|
544
|
+
".mp4": "video/mp4",
|
|
545
|
+
".m4v": "video/mp4",
|
|
546
|
+
".mov": "video/quicktime",
|
|
547
|
+
".webm": "video/webm",
|
|
548
|
+
},
|
|
549
|
+
maxBytes: 200 * MB,
|
|
550
|
+
accepts: "MP4, MOV, WebM",
|
|
551
|
+
},
|
|
552
|
+
audio: {
|
|
553
|
+
mimeByExtension: {
|
|
554
|
+
".mp3": "audio/mpeg",
|
|
555
|
+
".m4a": "audio/mp4",
|
|
556
|
+
".wav": "audio/wav",
|
|
557
|
+
".ogg": "audio/ogg",
|
|
558
|
+
},
|
|
559
|
+
maxBytes: 50 * MB,
|
|
560
|
+
accepts: "MP3, M4A, WAV, OGG",
|
|
561
|
+
},
|
|
562
|
+
document: {
|
|
563
|
+
mimeByExtension: {
|
|
564
|
+
".pdf": "application/pdf",
|
|
565
|
+
".txt": "text/plain",
|
|
566
|
+
".md": "text/markdown",
|
|
567
|
+
".doc": "application/msword",
|
|
568
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
569
|
+
},
|
|
570
|
+
maxBytes: 25 * MB,
|
|
571
|
+
accepts: "PDF, TXT, Markdown, DOC, DOCX",
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
const MEDIA_FAMILIES = ["image", "video", "audio", "document"];
|
|
575
|
+
function sizeLabel(bytes) {
|
|
576
|
+
return `${(bytes / MB).toFixed(1)}MB`;
|
|
577
|
+
}
|
|
578
|
+
function capLabel(bytes) {
|
|
579
|
+
return `${Math.round(bytes / MB)}MB`;
|
|
580
|
+
}
|
|
581
|
+
function assetMimeFor(filePath, assetType) {
|
|
582
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
583
|
+
if (!ext)
|
|
584
|
+
return null;
|
|
585
|
+
for (const family of assetType ? [assetType] : MEDIA_FAMILIES) {
|
|
586
|
+
const mime = ASSET_UPLOAD_POLICY[family].mimeByExtension[ext];
|
|
587
|
+
if (mime)
|
|
588
|
+
return { mime, family };
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
function acceptedLabel(assetType) {
|
|
593
|
+
if (assetType) {
|
|
594
|
+
const policy = ASSET_UPLOAD_POLICY[assetType];
|
|
595
|
+
return `${assetType} files (${policy.accepts}, up to ${capLabel(policy.maxBytes)})`;
|
|
596
|
+
}
|
|
597
|
+
return MEDIA_FAMILIES.map((family) => `${family} (${ASSET_UPLOAD_POLICY[family].accepts})`).join(", ");
|
|
598
|
+
}
|
|
599
|
+
function looksLikePath(value) {
|
|
600
|
+
if (value.includes("/") || value.includes("\\"))
|
|
601
|
+
return true;
|
|
602
|
+
if (value.startsWith("~"))
|
|
603
|
+
return true;
|
|
604
|
+
return /\.[A-Za-z0-9]{1,8}$/.test(value);
|
|
605
|
+
}
|
|
606
|
+
function planAssetUpload(field, filePath, size, assetType, deps) {
|
|
607
|
+
const name = path.basename(filePath);
|
|
608
|
+
const picked = assetMimeFor(filePath, assetType);
|
|
609
|
+
if (!picked) {
|
|
610
|
+
throw new Error(`--input ${field}: can't tell what kind of file "${name}" is — ${field} takes ${acceptedLabel(assetType)}`);
|
|
611
|
+
}
|
|
612
|
+
const { maxBytes } = ASSET_UPLOAD_POLICY[picked.family];
|
|
613
|
+
if (size > maxBytes) {
|
|
614
|
+
throw new Error(`--input ${field}: "${name}" is ${sizeLabel(size)} — over the ${capLabel(maxBytes)} limit for ${picked.family} uploads`);
|
|
615
|
+
}
|
|
616
|
+
if (!deps.readFileBytes || !deps.uploadBytes) {
|
|
617
|
+
throw new Error(`--input ${field}: cannot upload files here (no file access)`);
|
|
618
|
+
}
|
|
619
|
+
return { field, filePath, name, size, mime: picked.mime, family: picked.family };
|
|
620
|
+
}
|
|
621
|
+
async function pushAssetFile(plan, deps) {
|
|
622
|
+
const { field, name } = plan;
|
|
623
|
+
const mint = await deps.post(ASSET_UPLOAD_URL_PATH, {});
|
|
624
|
+
if (!mint.ok) {
|
|
625
|
+
throw new Error(missingRouteLine(mint, "workflow file inputs") ?? formatApiError(mint));
|
|
626
|
+
}
|
|
627
|
+
const minted = mint.data;
|
|
628
|
+
if (!minted.uploadUrl) {
|
|
629
|
+
throw new Error(`--input ${field}: the server did not return an upload URL for "${name}"`);
|
|
630
|
+
}
|
|
631
|
+
if (!minted.receiptId) {
|
|
632
|
+
throw new Error(`--input ${field}: the server did not return an upload receipt for "${name}"`);
|
|
633
|
+
}
|
|
634
|
+
let bytes;
|
|
635
|
+
try {
|
|
636
|
+
bytes = deps.readFileBytes(plan.filePath);
|
|
637
|
+
}
|
|
638
|
+
catch (e) {
|
|
639
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
640
|
+
throw new Error(`--input ${field}: could not read file "${plan.filePath}": ${msg}`);
|
|
641
|
+
}
|
|
642
|
+
const put = await deps.uploadBytes(minted.uploadUrl, plan.mime, bytes);
|
|
643
|
+
if (!put.ok || !put.storageId) {
|
|
644
|
+
const detail = put.body ? `: ${put.body.slice(0, 200)}` : "";
|
|
645
|
+
throw new Error(`--input ${field}: upload of "${name}" failed (HTTP ${put.status})${detail}`);
|
|
646
|
+
}
|
|
647
|
+
const registered = await deps.post(ASSETS_PATH, {
|
|
648
|
+
storageId: put.storageId,
|
|
649
|
+
receiptId: minted.receiptId,
|
|
650
|
+
filename: name,
|
|
651
|
+
});
|
|
652
|
+
if (!registered.ok) {
|
|
653
|
+
throw new Error(missingRouteLine(registered, "workflow file inputs") ?? formatApiError(registered));
|
|
654
|
+
}
|
|
655
|
+
const data = registered.data;
|
|
656
|
+
if (!data.assetId) {
|
|
657
|
+
throw new Error(`--input ${field}: the server did not return an asset id for "${name}"`);
|
|
658
|
+
}
|
|
659
|
+
return { assetId: data.assetId, mediaType: data.mediaType ?? plan.family };
|
|
660
|
+
}
|
|
661
|
+
function formatInputProblems(problems) {
|
|
662
|
+
if (problems.length === 1)
|
|
663
|
+
return problems[0];
|
|
664
|
+
return [
|
|
665
|
+
`${problems.length} of the --input values can't be used:`,
|
|
666
|
+
...problems.map((line) => ` ${line}`),
|
|
667
|
+
].join("\n");
|
|
668
|
+
}
|
|
669
|
+
function withReusableAssetIds(message, done) {
|
|
670
|
+
if (done.length === 0)
|
|
671
|
+
return message;
|
|
672
|
+
const flags = done.map((d) => `--input ${d.field}=${d.assetId}`).join(" ");
|
|
673
|
+
return (`${message}\n` +
|
|
674
|
+
`These files did upload — pass their ids to skip re-uploading them: ${flags}`);
|
|
675
|
+
}
|
|
676
|
+
function looksLikeFileArgument(value, deps) {
|
|
677
|
+
if (value.startsWith("@@"))
|
|
678
|
+
return false;
|
|
679
|
+
if (value.startsWith("@"))
|
|
680
|
+
return true;
|
|
681
|
+
if ((deps.statFile?.(value) ?? null) !== null)
|
|
682
|
+
return true;
|
|
683
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value))
|
|
684
|
+
return false;
|
|
685
|
+
if (/\s/.test(value))
|
|
686
|
+
return false;
|
|
687
|
+
return looksLikePath(value);
|
|
688
|
+
}
|
|
689
|
+
export const NO_DELIVERIES_WARNING = "Warning: this workflow has no Output node — the run will finish with no named " +
|
|
690
|
+
"deliveries (nothing for --out to save, and nothing to send to a webhook).";
|
|
691
|
+
export function noDeliveriesWarning(described) {
|
|
692
|
+
if (!isRecord(described))
|
|
693
|
+
return undefined;
|
|
694
|
+
const deliveries = described["deliveries"];
|
|
695
|
+
if (Array.isArray(deliveries)) {
|
|
696
|
+
return deliveries.length === 0 ? NO_DELIVERIES_WARNING : undefined;
|
|
697
|
+
}
|
|
698
|
+
return undefined;
|
|
699
|
+
}
|
|
700
|
+
export function serverWarningsToPrint(serverWarnings, alreadyPrinted) {
|
|
701
|
+
if (!Array.isArray(serverWarnings))
|
|
702
|
+
return [];
|
|
703
|
+
const key = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
704
|
+
const printedKeys = new Set(alreadyPrinted.map(key));
|
|
705
|
+
const saidNoDeliveries = alreadyPrinted.includes(NO_DELIVERIES_WARNING);
|
|
706
|
+
const out = [];
|
|
707
|
+
for (const raw of serverWarnings) {
|
|
708
|
+
if (typeof raw !== "string")
|
|
709
|
+
continue;
|
|
710
|
+
const text = raw.trim();
|
|
711
|
+
if (text === "")
|
|
712
|
+
continue;
|
|
713
|
+
const k = key(text);
|
|
714
|
+
if (printedKeys.has(k))
|
|
715
|
+
continue;
|
|
716
|
+
if (saidNoDeliveries && (k.includes("no output node") || k.includes("no named deliver"))) {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
printedKeys.add(k);
|
|
720
|
+
out.push(text);
|
|
721
|
+
}
|
|
722
|
+
return out;
|
|
723
|
+
}
|
|
724
|
+
async function prepareRunInputs(workflowId, raw, deps, note, scoped) {
|
|
725
|
+
const described = await deps.get(`${DESCRIBE_PATH}?id=${encodeURIComponent(workflowId)}`);
|
|
726
|
+
if (!described.ok) {
|
|
727
|
+
const fileish = Object.keys(raw).filter((key) => looksLikeFileArgument(raw[key], deps));
|
|
728
|
+
if (fileish.length > 0) {
|
|
729
|
+
const reason = formatApiError(described).split("\n")[0];
|
|
730
|
+
throw new Error(`--input ${fileish.join(", ")}: couldn't confirm this workflow's inputs ` +
|
|
731
|
+
`(describe failed: ${reason}) — retry, or pass an already-uploaded asset id ` +
|
|
732
|
+
`instead of a file path.`);
|
|
733
|
+
}
|
|
734
|
+
const text = {};
|
|
735
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
736
|
+
text[key] = expandInputValue(key, value, deps.readFile);
|
|
737
|
+
}
|
|
738
|
+
return { inputs: text, warnings: [] };
|
|
739
|
+
}
|
|
740
|
+
const warnings = [];
|
|
741
|
+
const noDeliveries = noDeliveriesWarning(described.data);
|
|
742
|
+
if (noDeliveries)
|
|
743
|
+
warnings.push(noDeliveries);
|
|
744
|
+
const descriptors = described.data.inputs ?? [];
|
|
745
|
+
const assetFields = new Map();
|
|
746
|
+
const multiFields = new Set();
|
|
747
|
+
for (const descriptor of descriptors) {
|
|
748
|
+
if (descriptor?.source === "asset")
|
|
749
|
+
assetFields.set(descriptor.fieldName, descriptor);
|
|
750
|
+
if (descriptor?.type === "multi-select")
|
|
751
|
+
multiFields.add(descriptor.fieldName);
|
|
752
|
+
}
|
|
753
|
+
if (!scoped) {
|
|
754
|
+
const missing = [...assetFields.values()].filter((d) => d.required && (raw[d.fieldName] ?? "").trim() === "");
|
|
755
|
+
if (missing.length > 0) {
|
|
756
|
+
const names = missing.map((d) => d.fieldName).join(", ");
|
|
757
|
+
const first = missing[0];
|
|
758
|
+
throw new Error(`Missing required file input(s): ${names}. Pass each one as a local file ` +
|
|
759
|
+
`or a URL — e.g. --input ${first.fieldName}=./path/to/file or ` +
|
|
760
|
+
`--input ${first.fieldName}=https://…` +
|
|
761
|
+
(first.assetType ? ` (${acceptedLabel(first.assetType)})` : ""));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const prepared = {};
|
|
765
|
+
const planned = [];
|
|
766
|
+
const problems = [];
|
|
767
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
768
|
+
const descriptor = assetFields.get(key);
|
|
769
|
+
if (!descriptor) {
|
|
770
|
+
try {
|
|
771
|
+
const expanded = expandInputValue(key, value, deps.readFile);
|
|
772
|
+
prepared[key] = multiFields.has(key)
|
|
773
|
+
? normalizeMultiValue(expanded)
|
|
774
|
+
: expanded;
|
|
775
|
+
}
|
|
776
|
+
catch (e) {
|
|
777
|
+
problems.push(e instanceof Error ? e.message : String(e));
|
|
778
|
+
}
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (/^https?:\/\//i.test(value)) {
|
|
782
|
+
prepared[key] = value;
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const hinted = value.startsWith("@");
|
|
786
|
+
const candidate = hinted ? value.slice(1) : value;
|
|
787
|
+
const stat = deps.statFile?.(candidate) ?? null;
|
|
788
|
+
if (!stat) {
|
|
789
|
+
if (hinted || looksLikePath(candidate)) {
|
|
790
|
+
problems.push(`--input ${key}: file not found: ${candidate}`);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
prepared[key] = value;
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
try {
|
|
797
|
+
planned.push(planAssetUpload(key, candidate, stat.size, descriptor.assetType, deps));
|
|
798
|
+
}
|
|
799
|
+
catch (e) {
|
|
800
|
+
problems.push(e instanceof Error ? e.message : String(e));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (problems.length > 0)
|
|
804
|
+
throw new Error(formatInputProblems(problems));
|
|
805
|
+
const done = [];
|
|
806
|
+
for (const plan of planned) {
|
|
807
|
+
let uploaded;
|
|
808
|
+
try {
|
|
809
|
+
uploaded = await pushAssetFile(plan, deps);
|
|
810
|
+
}
|
|
811
|
+
catch (e) {
|
|
812
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
813
|
+
throw new Error(withReusableAssetIds(message, done));
|
|
814
|
+
}
|
|
815
|
+
note(` uploaded ${plan.name} → ${plan.field} (${uploaded.mediaType}, ${sizeLabel(plan.size)})`);
|
|
816
|
+
prepared[plan.field] = uploaded.assetId;
|
|
817
|
+
done.push({ field: plan.field, assetId: uploaded.assetId });
|
|
818
|
+
}
|
|
819
|
+
return { inputs: prepared, warnings };
|
|
820
|
+
}
|
|
821
|
+
export function parseTerminalFlags(args) {
|
|
822
|
+
const ids = [];
|
|
823
|
+
for (let i = 0; i < args.length; i++) {
|
|
824
|
+
const arg = args[i];
|
|
825
|
+
let raw;
|
|
826
|
+
if (arg === "--terminal") {
|
|
827
|
+
raw = args[i + 1];
|
|
828
|
+
i++;
|
|
829
|
+
}
|
|
830
|
+
else if (arg.startsWith("--terminal=")) {
|
|
831
|
+
raw = arg.slice("--terminal=".length);
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (raw === undefined)
|
|
837
|
+
throw new Error("--terminal requires a node id");
|
|
838
|
+
const id = raw.trim();
|
|
839
|
+
if (!id)
|
|
840
|
+
throw new Error("--terminal requires a node id");
|
|
841
|
+
ids.push(id);
|
|
842
|
+
}
|
|
843
|
+
return ids;
|
|
844
|
+
}
|
|
845
|
+
export function parseAutoApproveFlag(args) {
|
|
846
|
+
for (const arg of args) {
|
|
847
|
+
if (arg === "--auto-approve")
|
|
848
|
+
return true;
|
|
849
|
+
if (arg.startsWith("--auto-approve=")) {
|
|
850
|
+
throw new Error("--auto-approve takes no value — pass it bare");
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
export function parseRigOverridesFlag(args, readFile) {
|
|
856
|
+
let raw;
|
|
857
|
+
for (let i = 0; i < args.length; i++) {
|
|
858
|
+
const arg = args[i];
|
|
859
|
+
let value;
|
|
860
|
+
if (arg === "--rig-overrides") {
|
|
861
|
+
value = args[i + 1];
|
|
862
|
+
i++;
|
|
863
|
+
}
|
|
864
|
+
else if (arg.startsWith("--rig-overrides=")) {
|
|
865
|
+
value = arg.slice("--rig-overrides=".length);
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (value === undefined || value.startsWith("--")) {
|
|
871
|
+
throw new Error("--rig-overrides requires JSON or @path/to/file.json");
|
|
872
|
+
}
|
|
873
|
+
raw = value;
|
|
874
|
+
}
|
|
875
|
+
if (raw === undefined)
|
|
876
|
+
return undefined;
|
|
877
|
+
let text = raw.trim();
|
|
878
|
+
if (!text)
|
|
879
|
+
throw new Error("--rig-overrides requires JSON or @path/to/file.json");
|
|
880
|
+
if (text.startsWith("@@")) {
|
|
881
|
+
text = text.slice(1);
|
|
882
|
+
}
|
|
883
|
+
else if (text.startsWith("@")) {
|
|
884
|
+
const filePath = text.slice(1);
|
|
885
|
+
if (!filePath) {
|
|
886
|
+
throw new Error("--rig-overrides @<file> needs a file path after \"@\"");
|
|
887
|
+
}
|
|
888
|
+
if (!readFile) {
|
|
889
|
+
throw new Error(`--rig-overrides: cannot load @${filePath} here (no file access)`);
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
text = readFile(filePath);
|
|
893
|
+
}
|
|
894
|
+
catch (e) {
|
|
895
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
896
|
+
throw new Error(`--rig-overrides: could not read file "${filePath}": ${msg}`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
let parsed;
|
|
900
|
+
try {
|
|
901
|
+
parsed = JSON.parse(text);
|
|
902
|
+
}
|
|
903
|
+
catch (e) {
|
|
904
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
905
|
+
throw new Error(`--rig-overrides is not valid JSON: ${msg}`);
|
|
906
|
+
}
|
|
907
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
908
|
+
throw new Error("--rig-overrides must be a JSON object keyed by Image Rig node id, e.g. '{\"rig_1\":{\"lines\":{\"line_1\":{\"count\":3}}}}'");
|
|
909
|
+
}
|
|
910
|
+
return parsed;
|
|
911
|
+
}
|
|
912
|
+
export function parseFillFlag(args) {
|
|
913
|
+
let name;
|
|
914
|
+
for (let i = 0; i < args.length; i++) {
|
|
915
|
+
const arg = args[i];
|
|
916
|
+
let raw;
|
|
917
|
+
if (arg === "--fill") {
|
|
918
|
+
raw = args[i + 1];
|
|
919
|
+
i++;
|
|
920
|
+
}
|
|
921
|
+
else if (arg.startsWith("--fill=")) {
|
|
922
|
+
raw = arg.slice("--fill=".length);
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
if (raw === undefined || raw.startsWith("--")) {
|
|
928
|
+
throw new Error("--fill requires a saved fill's name");
|
|
929
|
+
}
|
|
930
|
+
const trimmed = raw.trim();
|
|
931
|
+
if (!trimmed)
|
|
932
|
+
throw new Error("--fill requires a saved fill's name");
|
|
933
|
+
name = trimmed;
|
|
934
|
+
}
|
|
935
|
+
return name;
|
|
936
|
+
}
|
|
170
937
|
export function formatWorkflowList(workflows) {
|
|
171
938
|
if (workflows.length === 0)
|
|
172
939
|
return "No workflows found for the active brand.";
|
|
@@ -181,23 +948,61 @@ export function formatWorkflowList(workflows) {
|
|
|
181
948
|
export function formatRecentRuns(runs) {
|
|
182
949
|
if (runs.length === 0)
|
|
183
950
|
return "No workflow runs found for the active brand.";
|
|
184
|
-
return table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, r.status, dateOnly(r.createdAt), r._id]));
|
|
951
|
+
return table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, runStatusLabel(r.status), dateOnly(r.createdAt), r._id]));
|
|
952
|
+
}
|
|
953
|
+
export function formatWorkflowVersions(versions) {
|
|
954
|
+
if (versions.length === 0) {
|
|
955
|
+
return "no saved versions yet — versions start recording on the workflow's next save";
|
|
956
|
+
}
|
|
957
|
+
const lines = versions.map((v) => {
|
|
958
|
+
const by = v.savedByName ? ` · by ${v.savedByName}` : "";
|
|
959
|
+
return `v${v.version} · ${v.name} · saved ${dateOnly(v.savedAt)}${by}`;
|
|
960
|
+
});
|
|
961
|
+
if (versions.length === VERSIONS_CAP) {
|
|
962
|
+
lines.push("");
|
|
963
|
+
lines.push(`(showing the ${VERSIONS_CAP} most recent versions — older versions may exist beyond this cap)`);
|
|
964
|
+
}
|
|
965
|
+
return lines.join("\n");
|
|
185
966
|
}
|
|
186
967
|
export function formatImportSummary(result, mode = {}) {
|
|
187
968
|
const lines = [];
|
|
188
|
-
const heading = mode.
|
|
189
|
-
? "Workflow
|
|
190
|
-
:
|
|
191
|
-
? "Workflow
|
|
192
|
-
:
|
|
193
|
-
? "Workflow
|
|
194
|
-
:
|
|
969
|
+
const heading = mode.validate
|
|
970
|
+
? "Workflow validation passed:"
|
|
971
|
+
: mode.dryRun
|
|
972
|
+
? "Workflow import preview:"
|
|
973
|
+
: result.created
|
|
974
|
+
? "Workflow imported."
|
|
975
|
+
: mode.update
|
|
976
|
+
? "Workflow updated."
|
|
977
|
+
: "Workflow import preview:";
|
|
195
978
|
lines.push(heading);
|
|
196
979
|
lines.push(`name: ${result.name}`);
|
|
197
980
|
if (result.workflowId)
|
|
198
981
|
lines.push(`workflowId: ${result.workflowId}`);
|
|
199
982
|
lines.push(`nodes: ${result.nodeCount}`);
|
|
200
983
|
lines.push(`edges: ${result.edgeCount}`);
|
|
984
|
+
if (result.triggers && result.triggers.length > 0) {
|
|
985
|
+
lines.push("");
|
|
986
|
+
const enabledCount = result.triggers.filter((t) => t.enabled).length;
|
|
987
|
+
lines.push(`Triggers (${result.triggers.length}):`);
|
|
988
|
+
for (const t of result.triggers) {
|
|
989
|
+
const detail = t.type === "event" ? `event (${t.event})` : `cron (${t.cron})`;
|
|
990
|
+
if (t.enabled) {
|
|
991
|
+
const fires = t.type === "event"
|
|
992
|
+
? t.event === "winner-promoted"
|
|
993
|
+
? "fires on every promote"
|
|
994
|
+
: `fires on ${t.event}`
|
|
995
|
+
: `fires on schedule ${t.cron}`;
|
|
996
|
+
lines.push(` ⚠ ${detail} — ENABLED, ${fires}`);
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
lines.push(` ${detail} — disabled`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (enabledCount > 0) {
|
|
1003
|
+
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>`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
201
1006
|
if (result.unresolved.length > 0) {
|
|
202
1007
|
lines.push("");
|
|
203
1008
|
lines.push(`Unresolved references (${result.unresolved.length}):`);
|
|
@@ -221,7 +1026,7 @@ export function formatWorkflowRun(run) {
|
|
|
221
1026
|
lines.push(`workflowId: ${run.workflowId}`);
|
|
222
1027
|
if (run.triggerRunId)
|
|
223
1028
|
lines.push(`triggerRunId: ${run.triggerRunId}`);
|
|
224
|
-
lines.push(`verdict: ${run.status}${counts ? ` (${counts})` : ""}`);
|
|
1029
|
+
lines.push(`verdict: ${runStatusLabel(run.status)}${counts ? ` (${counts})` : ""}`);
|
|
225
1030
|
if (run.isTerminal)
|
|
226
1031
|
lines.push("terminal: yes");
|
|
227
1032
|
if (run.error)
|
|
@@ -230,47 +1035,244 @@ export function formatWorkflowRun(run) {
|
|
|
230
1035
|
const inputs = Object.entries(run.inputs).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
231
1036
|
lines.push(`inputs: ${inputs}`);
|
|
232
1037
|
}
|
|
1038
|
+
if (run.autoApprovals && run.autoApprovals.length > 0) {
|
|
1039
|
+
lines.push(`auto-approved: ${run.autoApprovals.length} checkpoint stop${run.autoApprovals.length === 1 ? "" : "s"} (${run.autoApprovals.map((a) => a.nodeId).join(", ")}) — launched with --auto-approve, nobody reviewed these`);
|
|
1040
|
+
}
|
|
1041
|
+
const parkedNodeId = gateParkedNodeId(run.status, run.pauseReason, run.pausedNodeId);
|
|
233
1042
|
if (run.nodes.length > 0) {
|
|
234
1043
|
lines.push("");
|
|
235
1044
|
lines.push(`Nodes (${run.nodes.length}):`);
|
|
236
1045
|
for (const node of run.nodes) {
|
|
237
|
-
lines.push(progressLine(node));
|
|
1046
|
+
lines.push(progressLine(node, parkedNodeId !== undefined && node.nodeId === parkedNodeId));
|
|
238
1047
|
for (const output of node.outputs)
|
|
239
1048
|
lines.push(...outputLines(output));
|
|
240
1049
|
}
|
|
241
1050
|
}
|
|
1051
|
+
if (run.deliveries && run.deliveries.length > 0) {
|
|
1052
|
+
lines.push("");
|
|
1053
|
+
lines.push(`Deliveries (${run.deliveries.length}):`);
|
|
1054
|
+
for (const delivery of run.deliveries)
|
|
1055
|
+
lines.push(...deliveryLines(delivery));
|
|
1056
|
+
}
|
|
242
1057
|
if (run.outputs && run.outputs.length > 0) {
|
|
243
1058
|
lines.push("");
|
|
244
1059
|
lines.push(`Outputs (${run.outputs.length}):`);
|
|
245
|
-
for (const output of run.outputs)
|
|
246
|
-
lines.push(...runOutputLines(output));
|
|
1060
|
+
for (const output of run.outputs) {
|
|
1061
|
+
lines.push(...runOutputLines(output, parkedNodeId !== undefined && output.nodeId === parkedNodeId));
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (run.sessions && run.sessions.length > 0) {
|
|
1065
|
+
lines.push("");
|
|
1066
|
+
lines.push(`Sessions (${run.sessions.length}):`);
|
|
1067
|
+
for (const s of run.sessions) {
|
|
1068
|
+
lines.push(` session: ${s.sessionId} · "${s.title}" · continue: exodus session chat ${s.sessionId} "..."`);
|
|
1069
|
+
}
|
|
247
1070
|
}
|
|
248
1071
|
return lines.join("\n");
|
|
249
1072
|
}
|
|
250
|
-
function runOutputLines(output) {
|
|
1073
|
+
function runOutputLines(output, awaitingReview = false) {
|
|
251
1074
|
const slug = output.botSlug ? ` (${output.botSlug})` : "";
|
|
1075
|
+
const review = awaitingReview ? " — awaiting approval, not yet approved" : "";
|
|
252
1076
|
if (output.type === "image") {
|
|
253
|
-
return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}`];
|
|
1077
|
+
return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}${review}`];
|
|
254
1078
|
}
|
|
255
1079
|
if (output.type === "video") {
|
|
256
1080
|
const tag = output.final === true ? "final video" : "video";
|
|
257
|
-
return [` ${output.label} [${tag}]${slug}: ${output.videoUrl ?? "(no url)"}`];
|
|
1081
|
+
return [` ${output.label} [${tag}]${slug}: ${output.videoUrl ?? "(no url)"}${review}`];
|
|
258
1082
|
}
|
|
259
1083
|
if (output.type === "audio") {
|
|
260
|
-
return [` ${output.label} [audio]${slug}: ${output.audioUrl ?? "(no url)"}`];
|
|
1084
|
+
return [` ${output.label} [audio]${slug}: ${output.audioUrl ?? "(no url)"}${review}`];
|
|
1085
|
+
}
|
|
1086
|
+
if (output.type === "document") {
|
|
1087
|
+
const name = output.filename ? ` ${output.filename}` : "";
|
|
1088
|
+
return [
|
|
1089
|
+
` ${output.label} [document]${slug}:${name} ${output.documentUrl ?? "(no url)"}${review}`,
|
|
1090
|
+
];
|
|
261
1091
|
}
|
|
262
1092
|
if (output.type === "frames") {
|
|
263
1093
|
const n = output.frames?.length ?? 0;
|
|
264
|
-
return [` ${output.label} [frames]${slug}: ${n} scene${n === 1 ? "" : "s"}`];
|
|
1094
|
+
return [` ${output.label} [frames]${slug}: ${n} scene${n === 1 ? "" : "s"}${review}`];
|
|
265
1095
|
}
|
|
266
1096
|
if (output.type === "storyboard") {
|
|
267
|
-
return [` ${output.label} [storyboard]${slug}: use --json for the scene plan`];
|
|
1097
|
+
return [` ${output.label} [storyboard]${slug}: use --json for the scene plan${review}`];
|
|
268
1098
|
}
|
|
269
1099
|
const raw = output.text ?? "";
|
|
270
1100
|
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
271
1101
|
const body = truncateText(raw, 400);
|
|
272
1102
|
const note = normalized.length > 400 ? "\n (truncated — use --json for the full text)" : "";
|
|
273
|
-
return [` ${output.label} [text]${slug}:`, ` ${body}${note}`];
|
|
1103
|
+
return [` ${output.label} [text]${slug}${review}:`, ` ${body}${note}`];
|
|
1104
|
+
}
|
|
1105
|
+
function deliveryLines(delivery) {
|
|
1106
|
+
const status = delivery.status === "unfulfilled" && delivery.error
|
|
1107
|
+
? `unfulfilled — ${delivery.error}`
|
|
1108
|
+
: delivery.status;
|
|
1109
|
+
const lines = [` ${delivery.label} (${delivery.key}) · ${delivery.type} · ${status}`];
|
|
1110
|
+
for (const artifact of delivery.artifacts) {
|
|
1111
|
+
for (const line of runOutputLines(artifact))
|
|
1112
|
+
lines.push(` ${line}`);
|
|
1113
|
+
}
|
|
1114
|
+
return lines;
|
|
1115
|
+
}
|
|
1116
|
+
const ASSET_FALLBACK_EXT = {
|
|
1117
|
+
image: "png",
|
|
1118
|
+
video: "mp4",
|
|
1119
|
+
audio: "mp3",
|
|
1120
|
+
};
|
|
1121
|
+
export function workflowFilenameSlug(name) {
|
|
1122
|
+
const slug = (name ?? "")
|
|
1123
|
+
.toLowerCase()
|
|
1124
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1125
|
+
.replace(/^-+|-+$/g, "");
|
|
1126
|
+
return slug || "workflow";
|
|
1127
|
+
}
|
|
1128
|
+
function extensionFromUrl(url) {
|
|
1129
|
+
const withoutQuery = url.split(/[?#]/)[0];
|
|
1130
|
+
const m = withoutQuery.match(/\.([a-z0-9]{1,5})$/i);
|
|
1131
|
+
return m ? m[1].toLowerCase() : undefined;
|
|
1132
|
+
}
|
|
1133
|
+
function planArtifactFile(artifact) {
|
|
1134
|
+
if (artifact.type === "text") {
|
|
1135
|
+
return { kind: "text", ext: "md", body: artifact.text ?? "" };
|
|
1136
|
+
}
|
|
1137
|
+
if (artifact.type === "storyboard") {
|
|
1138
|
+
const json = artifact.storyboardJson;
|
|
1139
|
+
if (!json)
|
|
1140
|
+
return { kind: "none", reason: "no storyboard JSON on this artifact" };
|
|
1141
|
+
return { kind: "text", ext: "json", body: json.endsWith("\n") ? json : `${json}\n` };
|
|
1142
|
+
}
|
|
1143
|
+
if (artifact.type === "frames") {
|
|
1144
|
+
return {
|
|
1145
|
+
kind: "text",
|
|
1146
|
+
ext: "json",
|
|
1147
|
+
body: `${JSON.stringify(artifact.frames ?? [], null, 2)}\n`,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
const url = artifact.type === "image"
|
|
1151
|
+
? (artifact.imageUrl ?? undefined)
|
|
1152
|
+
: artifact.type === "video"
|
|
1153
|
+
? artifact.videoUrl
|
|
1154
|
+
: artifact.type === "document"
|
|
1155
|
+
?
|
|
1156
|
+
artifact.documentUrl
|
|
1157
|
+
: artifact.audioUrl;
|
|
1158
|
+
if (!url)
|
|
1159
|
+
return { kind: "none", reason: "no downloadable URL on this artifact" };
|
|
1160
|
+
const filenameExt = artifact.type === "document" && artifact.filename
|
|
1161
|
+
? extensionFromUrl(artifact.filename)
|
|
1162
|
+
: undefined;
|
|
1163
|
+
return {
|
|
1164
|
+
kind: "download",
|
|
1165
|
+
ext: filenameExt ??
|
|
1166
|
+
extensionFromUrl(url) ??
|
|
1167
|
+
ASSET_FALLBACK_EXT[artifact.type] ??
|
|
1168
|
+
"bin",
|
|
1169
|
+
url,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
export async function saveDeliveries(run, dir, deps) {
|
|
1173
|
+
const paths = [];
|
|
1174
|
+
if (!run.isTerminal) {
|
|
1175
|
+
return {
|
|
1176
|
+
paths,
|
|
1177
|
+
lines: [
|
|
1178
|
+
`Nothing saved — this run isn't finished yet (${run.status}). Try again once it is: exodus workflow status --id ${run._id} --out ${dir}`,
|
|
1179
|
+
],
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
const deliveries = run.deliveries ?? [];
|
|
1183
|
+
if (deliveries.length === 0) {
|
|
1184
|
+
return {
|
|
1185
|
+
paths,
|
|
1186
|
+
lines: [
|
|
1187
|
+
"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.",
|
|
1188
|
+
],
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
const mkdirp = deps.mkdirp ?? defaultMkdirp;
|
|
1192
|
+
const downloadToFile = deps.downloadToFile ?? defaultDownloadToFile;
|
|
1193
|
+
mkdirp(dir);
|
|
1194
|
+
const stem = `${workflowFilenameSlug(run.workflowName)}-${run._id.slice(-8)}`;
|
|
1195
|
+
const lines = [`Saved deliveries to ${dir}:`];
|
|
1196
|
+
for (const delivery of deliveries) {
|
|
1197
|
+
if (delivery.status !== "delivered" || delivery.artifacts.length === 0) {
|
|
1198
|
+
const why = delivery.error !== undefined
|
|
1199
|
+
? `unfulfilled: ${delivery.error}`
|
|
1200
|
+
: delivery.status === "delivered"
|
|
1201
|
+
? "delivered but empty"
|
|
1202
|
+
: "unfulfilled";
|
|
1203
|
+
lines.push(` skipped ${delivery.label} (${delivery.key}) — ${why}`);
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
for (let i = 0; i < delivery.artifacts.length; i++) {
|
|
1207
|
+
const plan = planArtifactFile(delivery.artifacts[i]);
|
|
1208
|
+
const suffix = i === 0 ? "" : `-${i + 1}`;
|
|
1209
|
+
if (plan.kind === "none") {
|
|
1210
|
+
lines.push(` skipped ${delivery.label} (${delivery.key})${suffix} — ${plan.reason}`);
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
const file = nodePath.join(dir, `${stem}-${workflowFilenameSlug(delivery.key)}${suffix}.${plan.ext}`);
|
|
1214
|
+
try {
|
|
1215
|
+
if (plan.kind === "text")
|
|
1216
|
+
deps.writeFile(file, plan.body);
|
|
1217
|
+
else
|
|
1218
|
+
await downloadToFile(plan.url, file);
|
|
1219
|
+
lines.push(` wrote ${file}`);
|
|
1220
|
+
paths.push(file);
|
|
1221
|
+
}
|
|
1222
|
+
catch (e) {
|
|
1223
|
+
lines.push(` failed ${file} — ${e instanceof Error ? e.message : String(e)}`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
lines.push(`${paths.length} file${paths.length === 1 ? "" : "s"} written.`);
|
|
1228
|
+
return { lines, paths };
|
|
1229
|
+
}
|
|
1230
|
+
function inputValueHint(input) {
|
|
1231
|
+
const open = input.allowOther ? ", or your own answer" : "";
|
|
1232
|
+
if (input.type === "select") {
|
|
1233
|
+
const options = input.options ?? [];
|
|
1234
|
+
return options.length > 0 ? `one of: ${options.join(", ")}${open}` : undefined;
|
|
1235
|
+
}
|
|
1236
|
+
if (input.type === "multi-select") {
|
|
1237
|
+
const options = input.options ?? [];
|
|
1238
|
+
return options.length > 0
|
|
1239
|
+
? `one or more of: ${options.join(", ")} (comma-separated)${open}`
|
|
1240
|
+
: undefined;
|
|
1241
|
+
}
|
|
1242
|
+
if (input.type === "toggle")
|
|
1243
|
+
return "true or false";
|
|
1244
|
+
return undefined;
|
|
1245
|
+
}
|
|
1246
|
+
export function deliveryTypeWord(type) {
|
|
1247
|
+
switch (type) {
|
|
1248
|
+
case "text":
|
|
1249
|
+
return "text";
|
|
1250
|
+
case "structured":
|
|
1251
|
+
return "structured data";
|
|
1252
|
+
case "asset":
|
|
1253
|
+
return "file";
|
|
1254
|
+
case "collection":
|
|
1255
|
+
return "set of files";
|
|
1256
|
+
default:
|
|
1257
|
+
return type;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
export function deliveryContractLines(deliveries) {
|
|
1261
|
+
if (deliveries === undefined)
|
|
1262
|
+
return [];
|
|
1263
|
+
if (deliveries.length === 0)
|
|
1264
|
+
return ["Delivers: nothing — no Output node."];
|
|
1265
|
+
const lines = [`Delivers (${deliveries.length}):`];
|
|
1266
|
+
const ordered = deliveries
|
|
1267
|
+
.map((delivery, index) => ({ delivery, index }))
|
|
1268
|
+
.sort((a, b) => (a.delivery.order ?? a.index) - (b.delivery.order ?? b.index) || a.index - b.index)
|
|
1269
|
+
.map((entry) => entry.delivery);
|
|
1270
|
+
for (const delivery of ordered) {
|
|
1271
|
+
lines.push(` ${delivery.label} (${delivery.key}) — ${deliveryTypeWord(delivery.type)}`);
|
|
1272
|
+
if (delivery.description)
|
|
1273
|
+
lines.push(` ${delivery.description}`);
|
|
1274
|
+
}
|
|
1275
|
+
return lines;
|
|
274
1276
|
}
|
|
275
1277
|
export function formatDescribe(res) {
|
|
276
1278
|
const lines = [];
|
|
@@ -287,8 +1289,14 @@ export function formatDescribe(res) {
|
|
|
287
1289
|
else {
|
|
288
1290
|
for (const input of res.inputs) {
|
|
289
1291
|
const req = input.required ? "required" : "optional";
|
|
1292
|
+
const type = input.type && input.type !== "text" ? ` (${input.type})` : "";
|
|
290
1293
|
const bundle = input.bundleSize !== undefined ? `, bundle=${input.bundleSize}` : "";
|
|
291
|
-
|
|
1294
|
+
const family = input.source === "asset" && input.assetType ? ` (${input.assetType})` : "";
|
|
1295
|
+
const label = input.label ? ` (${input.label})` : "";
|
|
1296
|
+
lines.push(` ${input.fieldName}${label} — ${input.source}${family}${type}, ${req}${bundle}`);
|
|
1297
|
+
const hint = inputValueHint(input);
|
|
1298
|
+
if (hint)
|
|
1299
|
+
lines.push(` ${hint}`);
|
|
292
1300
|
if (input.description)
|
|
293
1301
|
lines.push(` ${input.description}`);
|
|
294
1302
|
}
|
|
@@ -322,6 +1330,11 @@ export function formatDescribe(res) {
|
|
|
322
1330
|
lines.push(` ${output.label} [${output.type}]${slug}`);
|
|
323
1331
|
}
|
|
324
1332
|
}
|
|
1333
|
+
const delivers = deliveryContractLines(res.deliveries);
|
|
1334
|
+
if (delivers.length > 0) {
|
|
1335
|
+
lines.push("");
|
|
1336
|
+
lines.push(...delivers);
|
|
1337
|
+
}
|
|
325
1338
|
return lines.join("\n");
|
|
326
1339
|
}
|
|
327
1340
|
function formatBotVocabulary(catalog) {
|
|
@@ -335,6 +1348,9 @@ function formatBotVocabulary(catalog) {
|
|
|
335
1348
|
lines.push(` aspect ratios: ${v.aspectRatios.join(", ")}`);
|
|
336
1349
|
lines.push(` custom bot: set a bot node's slug to "${catalog.customBot.slug}" and ` +
|
|
337
1350
|
`config.${catalog.customBot.configKey}=<genesis-slug> to reach any bot not listed here.`);
|
|
1351
|
+
lines.push(` prompt bot: set a bot node's slug to "${catalog.promptBot.slug}" and ` +
|
|
1352
|
+
`config.${catalog.promptBot.configKey}=<your instructions> — the prompt IS the bot ` +
|
|
1353
|
+
"(runs on the workspace's own LLM key, not Genesis).");
|
|
338
1354
|
return lines.join("\n");
|
|
339
1355
|
}
|
|
340
1356
|
export function formatBotsList(catalog, category) {
|
|
@@ -370,7 +1386,6 @@ export function formatBotDetail(bot) {
|
|
|
370
1386
|
lines.push(`category: ${bot.categoryLabel} (${bot.category})`);
|
|
371
1387
|
lines.push(`blurb: ${bot.blurb}`);
|
|
372
1388
|
lines.push(`outputType: ${bot.outputType}`);
|
|
373
|
-
lines.push(`splits: ${bot.splitsOutput ? "yes (can split into per-item deliverables)" : "no"}`);
|
|
374
1389
|
lines.push("");
|
|
375
1390
|
lines.push(`Input ports (${bot.inputs.length}):`);
|
|
376
1391
|
if (bot.inputs.length === 0) {
|
|
@@ -412,6 +1427,163 @@ export function formatBotDetail(bot) {
|
|
|
412
1427
|
}
|
|
413
1428
|
return lines.join("\n");
|
|
414
1429
|
}
|
|
1430
|
+
function firstLine(text) {
|
|
1431
|
+
const nl = text.indexOf("\n");
|
|
1432
|
+
return (nl === -1 ? text : text.slice(0, nl)).trim();
|
|
1433
|
+
}
|
|
1434
|
+
export function formatTemplatesList(templates) {
|
|
1435
|
+
if (templates.length === 0)
|
|
1436
|
+
return "No workflow templates available on this backend.";
|
|
1437
|
+
const lines = [
|
|
1438
|
+
table(["key", "label", "description"], templates.map((t) => [
|
|
1439
|
+
t.key,
|
|
1440
|
+
t.module ? `${t.label} [${t.module} module]` : t.label,
|
|
1441
|
+
firstLine(t.description ?? ""),
|
|
1442
|
+
])),
|
|
1443
|
+
];
|
|
1444
|
+
if (templates.some((t) => t.module)) {
|
|
1445
|
+
lines.push("");
|
|
1446
|
+
lines.push("Templates badged [<module> module] are owned by a module (e.g. video) — " +
|
|
1447
|
+
"their runs start from the show surfaces, not `workflow run`. Export one to " +
|
|
1448
|
+
"study or adapt its graph.");
|
|
1449
|
+
}
|
|
1450
|
+
lines.push("");
|
|
1451
|
+
lines.push("Export one to start authoring: exodus workflow templates export <key> --out my.yaml");
|
|
1452
|
+
return lines.join("\n");
|
|
1453
|
+
}
|
|
1454
|
+
const SCHEMA_LABEL_KEYS = ["kind", "face", "type", "name", "id", "code", "key", "label"];
|
|
1455
|
+
function schemaEntryLabel(entry) {
|
|
1456
|
+
for (const key of SCHEMA_LABEL_KEYS) {
|
|
1457
|
+
const v = entry[key];
|
|
1458
|
+
if (typeof v === "string" && v)
|
|
1459
|
+
return { key, value: v };
|
|
1460
|
+
}
|
|
1461
|
+
return undefined;
|
|
1462
|
+
}
|
|
1463
|
+
function schemaValueLines(value, indent) {
|
|
1464
|
+
if (value === null || value === undefined)
|
|
1465
|
+
return [`${indent}(none)`];
|
|
1466
|
+
if (Array.isArray(value)) {
|
|
1467
|
+
if (value.length === 0)
|
|
1468
|
+
return [`${indent}(none)`];
|
|
1469
|
+
const lines = [];
|
|
1470
|
+
for (const el of value) {
|
|
1471
|
+
if (isRecord(el)) {
|
|
1472
|
+
const label = schemaEntryLabel(el);
|
|
1473
|
+
if (label) {
|
|
1474
|
+
const parked = el["parked"] === true;
|
|
1475
|
+
lines.push(`${indent}- ${label.value}${parked ? " (parked)" : ""}`);
|
|
1476
|
+
const rest = { ...el };
|
|
1477
|
+
delete rest[label.key];
|
|
1478
|
+
if (parked)
|
|
1479
|
+
delete rest["parked"];
|
|
1480
|
+
lines.push(...schemaValueLines(rest, `${indent} `));
|
|
1481
|
+
}
|
|
1482
|
+
else {
|
|
1483
|
+
lines.push(`${indent}-`);
|
|
1484
|
+
lines.push(...schemaValueLines(el, `${indent} `));
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
else {
|
|
1488
|
+
lines.push(`${indent}- ${String(el)}`);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return lines;
|
|
1492
|
+
}
|
|
1493
|
+
if (isRecord(value)) {
|
|
1494
|
+
const entries = Object.entries(value);
|
|
1495
|
+
if (entries.length === 0)
|
|
1496
|
+
return [`${indent}(none)`];
|
|
1497
|
+
const lines = [];
|
|
1498
|
+
for (const [k, v] of entries) {
|
|
1499
|
+
if (isRecord(v) || Array.isArray(v)) {
|
|
1500
|
+
lines.push(`${indent}${k}:`);
|
|
1501
|
+
lines.push(...schemaValueLines(v, `${indent} `));
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
lines.push(`${indent}${k}: ${String(v)}`);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return lines;
|
|
1508
|
+
}
|
|
1509
|
+
return [`${indent}${String(value)}`];
|
|
1510
|
+
}
|
|
1511
|
+
const SCHEMA_SECTIONS = [
|
|
1512
|
+
{ title: "Graph contract version", keys: ["graphVersion"] },
|
|
1513
|
+
{ title: "Port types", keys: ["portTypes"] },
|
|
1514
|
+
{ title: "Node kinds", keys: ["nodeKinds"] },
|
|
1515
|
+
{ title: "Transform faces", keys: ["transformFaces"] },
|
|
1516
|
+
{ title: "Collector policy", keys: ["collectorPolicy"] },
|
|
1517
|
+
{ title: "Deposits", keys: ["deposits"] },
|
|
1518
|
+
{ title: "Slots", keys: ["slots"] },
|
|
1519
|
+
{ title: "Triggers", keys: ["triggers"] },
|
|
1520
|
+
{ title: "Edge / graph rules", keys: ["edgeRules", "graphRules"] },
|
|
1521
|
+
{
|
|
1522
|
+
title: "Bots",
|
|
1523
|
+
keys: ["botsPointer", "botsPointerNote", "bots-pointer-note", "botsNote", "bots"],
|
|
1524
|
+
},
|
|
1525
|
+
];
|
|
1526
|
+
function schemaSectionValue(payload, keys) {
|
|
1527
|
+
for (const key of keys) {
|
|
1528
|
+
if (payload[key] !== undefined)
|
|
1529
|
+
return { key, value: payload[key] };
|
|
1530
|
+
}
|
|
1531
|
+
return undefined;
|
|
1532
|
+
}
|
|
1533
|
+
export function formatSchema(payload) {
|
|
1534
|
+
const lines = [];
|
|
1535
|
+
const version = payload["version"];
|
|
1536
|
+
lines.push(`Workflow schema${version !== undefined ? ` (version ${version})` : ""}`);
|
|
1537
|
+
for (const section of SCHEMA_SECTIONS) {
|
|
1538
|
+
const found = schemaSectionValue(payload, section.keys);
|
|
1539
|
+
if (!found)
|
|
1540
|
+
continue;
|
|
1541
|
+
lines.push("");
|
|
1542
|
+
lines.push(`${section.title}:`);
|
|
1543
|
+
lines.push(...schemaValueLines(found.value, " "));
|
|
1544
|
+
}
|
|
1545
|
+
return lines.join("\n");
|
|
1546
|
+
}
|
|
1547
|
+
function schemaEntryIds(value) {
|
|
1548
|
+
if (!Array.isArray(value))
|
|
1549
|
+
return [];
|
|
1550
|
+
const ids = [];
|
|
1551
|
+
for (const el of value) {
|
|
1552
|
+
if (isRecord(el)) {
|
|
1553
|
+
const label = schemaEntryLabel(el);
|
|
1554
|
+
if (label)
|
|
1555
|
+
ids.push(label.value);
|
|
1556
|
+
}
|
|
1557
|
+
else if (typeof el === "string") {
|
|
1558
|
+
ids.push(el);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
return ids;
|
|
1562
|
+
}
|
|
1563
|
+
function formatSchemaFilter(payload, sectionKeys, axis, wanted) {
|
|
1564
|
+
const found = schemaSectionValue(payload, sectionKeys);
|
|
1565
|
+
const list = found && Array.isArray(found.value) ? found.value : [];
|
|
1566
|
+
const match = list.find((el) => {
|
|
1567
|
+
if (!isRecord(el))
|
|
1568
|
+
return typeof el === "string" && el === wanted;
|
|
1569
|
+
const label = schemaEntryLabel(el);
|
|
1570
|
+
return label?.value === wanted;
|
|
1571
|
+
});
|
|
1572
|
+
if (match === undefined) {
|
|
1573
|
+
const valid = schemaEntryIds(found?.value).join(", ") || "(none)";
|
|
1574
|
+
return { code: 1, lines: [`Unknown ${axis} "${wanted}". Valid ${axis}s: ${valid}`] };
|
|
1575
|
+
}
|
|
1576
|
+
const title = axis === "kind" ? "Node kind" : "Transform face";
|
|
1577
|
+
const lines = [`${title}: ${wanted}`];
|
|
1578
|
+
if (isRecord(match)) {
|
|
1579
|
+
const label = schemaEntryLabel(match);
|
|
1580
|
+
const rest = { ...match };
|
|
1581
|
+
if (label)
|
|
1582
|
+
delete rest[label.key];
|
|
1583
|
+
lines.push(...schemaValueLines(rest, " "));
|
|
1584
|
+
}
|
|
1585
|
+
return { code: 0, lines };
|
|
1586
|
+
}
|
|
415
1587
|
function graphIssueLines(issue) {
|
|
416
1588
|
const node = issue.nodeId ? ` [node ${issue.nodeId}]` : "";
|
|
417
1589
|
const port = issue.portId ? ` [port ${issue.portId}]` : "";
|
|
@@ -448,15 +1620,31 @@ function formatImportError(res) {
|
|
|
448
1620
|
lines.push(`fix: ${remedy}`);
|
|
449
1621
|
return lines.join("\n");
|
|
450
1622
|
}
|
|
451
|
-
return
|
|
1623
|
+
return formatApiError(res);
|
|
452
1624
|
}
|
|
453
|
-
|
|
1625
|
+
class WorkflowResolveError extends Error {
|
|
1626
|
+
status;
|
|
1627
|
+
data;
|
|
1628
|
+
constructor(status, data, message) {
|
|
1629
|
+
super(message);
|
|
1630
|
+
this.status = status;
|
|
1631
|
+
this.data = data;
|
|
1632
|
+
this.name = "WorkflowResolveError";
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
export async function resolveWorkflowId(ref, deps) {
|
|
454
1636
|
const res = await deps.get(LIST_PATH);
|
|
455
1637
|
if (!res.ok)
|
|
456
|
-
throw new
|
|
1638
|
+
throw new WorkflowResolveError(res.status, res.data, formatApiError(res));
|
|
457
1639
|
const workflows = res.data.workflows ?? [];
|
|
458
|
-
const
|
|
459
|
-
|
|
1640
|
+
const byName = workflows.find((w) => w.name.toLowerCase() === ref.toLowerCase());
|
|
1641
|
+
if (byName)
|
|
1642
|
+
return byName._id;
|
|
1643
|
+
const byId = workflows.find((w) => w._id === ref);
|
|
1644
|
+
if (byId)
|
|
1645
|
+
return byId._id;
|
|
1646
|
+
const message = `No workflow named "${ref}" on this brand — run: exodus workflow list`;
|
|
1647
|
+
throw new WorkflowResolveError(404, { error: { code: "NOT_FOUND", message } }, message);
|
|
460
1648
|
}
|
|
461
1649
|
export async function listFlow(json, deps) {
|
|
462
1650
|
const res = await deps.get(LIST_PATH);
|
|
@@ -474,7 +1662,7 @@ export async function describeFlow(workflowRef, opts, deps) {
|
|
|
474
1662
|
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
475
1663
|
}
|
|
476
1664
|
catch (e) {
|
|
477
|
-
return
|
|
1665
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
478
1666
|
}
|
|
479
1667
|
const res = await deps.get(`${DESCRIBE_PATH}?id=${encodeURIComponent(workflowId)}`);
|
|
480
1668
|
if (!res.ok)
|
|
@@ -508,85 +1696,201 @@ export async function runFlow(workflowRef, opts, deps) {
|
|
|
508
1696
|
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
509
1697
|
}
|
|
510
1698
|
catch (e) {
|
|
511
|
-
return
|
|
1699
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
1700
|
+
}
|
|
1701
|
+
const preface = [];
|
|
1702
|
+
const scoped = (opts.terminalNodeIds?.length ?? 0) > 0;
|
|
1703
|
+
let prepared;
|
|
1704
|
+
try {
|
|
1705
|
+
prepared = await prepareRunInputs(workflowId, opts.inputs, deps, (line) => {
|
|
1706
|
+
if (opts.json)
|
|
1707
|
+
return;
|
|
1708
|
+
if (opts.onProgressLine)
|
|
1709
|
+
opts.onProgressLine(line);
|
|
1710
|
+
else
|
|
1711
|
+
preface.push(line);
|
|
1712
|
+
}, scoped);
|
|
1713
|
+
}
|
|
1714
|
+
catch (e) {
|
|
1715
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1716
|
+
return {
|
|
1717
|
+
code: 1,
|
|
1718
|
+
lines: opts.json
|
|
1719
|
+
?
|
|
1720
|
+
[JSON.stringify({ ok: false, status: 400, data: { error: { code: "BAD_REQUEST", message } } })]
|
|
1721
|
+
: [...preface, message],
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
const inputs = prepared.inputs;
|
|
1725
|
+
const warned = [];
|
|
1726
|
+
for (const warning of prepared.warnings) {
|
|
1727
|
+
warned.push(warning);
|
|
1728
|
+
opts.onWarningLine?.(warning);
|
|
512
1729
|
}
|
|
513
1730
|
const body = {
|
|
514
1731
|
workflowId,
|
|
515
|
-
...(Object.keys(
|
|
1732
|
+
...(Object.keys(inputs).length > 0 ? { inputs } : {}),
|
|
1733
|
+
...(opts.fill && opts.fill.trim() !== "" ? { fill: opts.fill.trim() } : {}),
|
|
1734
|
+
...(opts.terminalNodeIds && opts.terminalNodeIds.length > 0
|
|
1735
|
+
? { terminalNodeIds: opts.terminalNodeIds }
|
|
1736
|
+
: {}),
|
|
1737
|
+
...(opts.autoApprove === true ? { autoApprove: true } : {}),
|
|
1738
|
+
...(opts.imageRigOverrides ? { imageRigOverrides: opts.imageRigOverrides } : {}),
|
|
1739
|
+
launchedVia: "cli",
|
|
516
1740
|
};
|
|
517
1741
|
const start = await deps.post(RUN_PATH, body);
|
|
518
1742
|
if (!start.ok)
|
|
519
1743
|
return asErrorResult(start, opts.json);
|
|
520
1744
|
const data = start.data;
|
|
1745
|
+
for (const warning of serverWarningsToPrint(data?.warnings, warned)) {
|
|
1746
|
+
warned.push(warning);
|
|
1747
|
+
opts.onWarningLine?.(warning);
|
|
1748
|
+
}
|
|
521
1749
|
const base = { ...data, workflowId };
|
|
522
1750
|
if (opts.json && !opts.wait)
|
|
523
1751
|
return { code: 0, lines: [JSON.stringify(base)] };
|
|
524
1752
|
const lines = opts.json
|
|
525
1753
|
? []
|
|
526
1754
|
: [
|
|
1755
|
+
...preface,
|
|
527
1756
|
"Workflow run started.",
|
|
528
1757
|
`runId: ${data.runId}`,
|
|
529
1758
|
`triggerRunId: ${data.triggerRunId}`,
|
|
530
1759
|
`Poll: exodus workflow status --id ${data.runId}`,
|
|
531
1760
|
];
|
|
532
|
-
if (!opts.wait)
|
|
1761
|
+
if (!opts.wait) {
|
|
1762
|
+
if (opts.out !== undefined && !opts.json) {
|
|
1763
|
+
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}`);
|
|
1764
|
+
}
|
|
533
1765
|
return { code: 0, lines };
|
|
1766
|
+
}
|
|
534
1767
|
if (!opts.json && opts.onProgressLine) {
|
|
535
1768
|
for (const line of lines)
|
|
536
1769
|
opts.onProgressLine(line);
|
|
537
1770
|
lines.length = 0;
|
|
538
1771
|
}
|
|
1772
|
+
const waited = await waitForRun(data.runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base, out: opts.out }, deps);
|
|
1773
|
+
if (opts.json)
|
|
1774
|
+
return waited;
|
|
1775
|
+
return { code: waited.code, lines: [...lines, ...waited.lines] };
|
|
1776
|
+
}
|
|
1777
|
+
const WAIT_TERMINAL_STATUSES = TERMINAL_RUN_STATUSES.flatMap((s) => storedWorkflowStatusForms(s));
|
|
1778
|
+
async function waitForRun(runId, opts, deps) {
|
|
539
1779
|
const seen = new Map();
|
|
540
1780
|
let pausedNotified = false;
|
|
1781
|
+
const landOnPark = opts.landOnPark;
|
|
541
1782
|
const pollResult = await deps.poll({
|
|
542
|
-
path: `${STATUS_PATH}?runId=${encodeURIComponent(
|
|
1783
|
+
path: `${STATUS_PATH}?runId=${encodeURIComponent(runId)}`,
|
|
543
1784
|
intervalMs: 3_000,
|
|
544
1785
|
timeoutMs: 60 * 60 * 1000,
|
|
545
|
-
terminalStatuses:
|
|
1786
|
+
terminalStatuses: landOnPark
|
|
1787
|
+
? [...WAIT_TERMINAL_STATUSES, ...storedWorkflowStatusForms("awaiting-approval")]
|
|
1788
|
+
: WAIT_TERMINAL_STATUSES,
|
|
1789
|
+
...(landOnPark
|
|
1790
|
+
? {
|
|
1791
|
+
isDone: (raw) => !(typeof raw["status"] === "string" &&
|
|
1792
|
+
normalizeRunStatus(raw["status"]) === "awaiting-approval") || raw["pauseReason"] === landOnPark.pauseReason,
|
|
1793
|
+
}
|
|
1794
|
+
: {}),
|
|
546
1795
|
onProgress: (raw) => {
|
|
547
1796
|
if (opts.json || !opts.onProgressLine)
|
|
548
1797
|
return;
|
|
549
|
-
|
|
1798
|
+
const rawStatus = raw["status"];
|
|
1799
|
+
const parked = typeof rawStatus === "string" &&
|
|
1800
|
+
normalizeRunStatus(rawStatus) === "awaiting-approval";
|
|
1801
|
+
const isLanding = landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason;
|
|
1802
|
+
if (parked && !pausedNotified && !isLanding) {
|
|
550
1803
|
pausedNotified = true;
|
|
551
|
-
|
|
1804
|
+
const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
|
|
1805
|
+
const pauseReason = raw["pauseReason"];
|
|
1806
|
+
for (const line of formatPauseNotice(pauseReason, runId, dashboardUrl)) {
|
|
1807
|
+
opts.onProgressLine(line);
|
|
1808
|
+
}
|
|
552
1809
|
}
|
|
1810
|
+
const parkedNodeId = gateParkedNodeId(raw["status"], raw["pauseReason"], raw["pausedNodeId"]);
|
|
553
1811
|
const nodes = Array.isArray(raw["nodes"]) ? raw["nodes"] : [];
|
|
554
1812
|
for (const node of nodes) {
|
|
555
|
-
|
|
1813
|
+
const isParked = parkedNodeId !== undefined && node.nodeId === parkedNodeId;
|
|
1814
|
+
const renderKey = `${node.status}:${isParked}`;
|
|
1815
|
+
if (seen.get(node.nodeId) === renderKey)
|
|
556
1816
|
continue;
|
|
557
|
-
seen.set(node.nodeId,
|
|
558
|
-
opts.onProgressLine(progressLine(node));
|
|
1817
|
+
seen.set(node.nodeId, renderKey);
|
|
1818
|
+
opts.onProgressLine(progressLine(node, isParked));
|
|
559
1819
|
}
|
|
560
1820
|
},
|
|
561
1821
|
});
|
|
1822
|
+
const terminalRun = !pollResult.timedOut &&
|
|
1823
|
+
isRecord(pollResult.data) &&
|
|
1824
|
+
typeof pollResult.data["_id"] === "string"
|
|
1825
|
+
? pollResult.data
|
|
1826
|
+
: undefined;
|
|
1827
|
+
const saved = opts.out !== undefined && terminalRun
|
|
1828
|
+
? await saveDeliveries(terminalRun, opts.out, deps)
|
|
1829
|
+
: undefined;
|
|
562
1830
|
if (opts.json) {
|
|
563
1831
|
return {
|
|
564
1832
|
code: pollResult.ok ? 0 : 1,
|
|
565
|
-
lines: [
|
|
1833
|
+
lines: [
|
|
1834
|
+
JSON.stringify({
|
|
1835
|
+
...opts.jsonBase,
|
|
1836
|
+
result: pollResult.data,
|
|
1837
|
+
timedOut: pollResult.timedOut,
|
|
1838
|
+
...(saved ? { saved: saved.paths } : {}),
|
|
1839
|
+
}),
|
|
1840
|
+
],
|
|
566
1841
|
};
|
|
567
1842
|
}
|
|
568
1843
|
if (pollResult.timedOut) {
|
|
569
|
-
|
|
570
|
-
|
|
1844
|
+
return {
|
|
1845
|
+
code: 1,
|
|
1846
|
+
lines: [`Timed out waiting. Check later: exodus workflow status --id ${runId}`],
|
|
1847
|
+
};
|
|
571
1848
|
}
|
|
572
|
-
lines
|
|
573
|
-
if (
|
|
574
|
-
lines.push(formatWorkflowRun(
|
|
1849
|
+
const lines = [""];
|
|
1850
|
+
if (terminalRun) {
|
|
1851
|
+
lines.push(formatWorkflowRun(terminalRun));
|
|
575
1852
|
}
|
|
576
1853
|
else {
|
|
577
1854
|
lines.push(`Polling failed: ${JSON.stringify(pollResult.data)}`);
|
|
578
1855
|
}
|
|
1856
|
+
if (saved)
|
|
1857
|
+
lines.push("", ...saved.lines);
|
|
1858
|
+
if (landOnPark &&
|
|
1859
|
+
isRecord(pollResult.data) &&
|
|
1860
|
+
typeof pollResult.data["status"] === "string" &&
|
|
1861
|
+
normalizeRunStatus(pollResult.data["status"]) === "awaiting-approval" &&
|
|
1862
|
+
pollResult.data["pauseReason"] === landOnPark.pauseReason) {
|
|
1863
|
+
const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
|
|
1864
|
+
lines.push("", landOnPark.headline, ...formatPauseNotice(landOnPark.pauseReason, runId, dashboardUrl).slice(1));
|
|
1865
|
+
}
|
|
579
1866
|
return { code: pollResult.ok ? 0 : 1, lines };
|
|
580
1867
|
}
|
|
581
1868
|
export async function statusFlow(opts, deps) {
|
|
1869
|
+
if (opts.out !== undefined && !opts.id) {
|
|
1870
|
+
return {
|
|
1871
|
+
code: 1,
|
|
1872
|
+
lines: [
|
|
1873
|
+
"--out saves ONE run's outputs, so it needs the run: exodus workflow status --id <runId> --out <dir>",
|
|
1874
|
+
],
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
582
1877
|
const path = opts.id ? `${STATUS_PATH}?runId=${encodeURIComponent(opts.id)}` : STATUS_PATH;
|
|
583
1878
|
const res = await deps.get(path);
|
|
584
1879
|
if (!res.ok)
|
|
585
1880
|
return asErrorResult(res, opts.json);
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1881
|
+
const saved = opts.out !== undefined && opts.id
|
|
1882
|
+
? await saveDeliveries(res.data, opts.out, deps)
|
|
1883
|
+
: undefined;
|
|
1884
|
+
if (opts.json) {
|
|
1885
|
+
const payload = saved ? { ...res.data, saved: saved.paths } : res.data;
|
|
1886
|
+
return { code: 0, lines: [JSON.stringify(payload)] };
|
|
1887
|
+
}
|
|
1888
|
+
if (opts.id) {
|
|
1889
|
+
const lines = [formatWorkflowRun(res.data)];
|
|
1890
|
+
if (saved)
|
|
1891
|
+
lines.push("", ...saved.lines);
|
|
1892
|
+
return { code: 0, lines };
|
|
1893
|
+
}
|
|
590
1894
|
const runs = (res.data.runs ?? []);
|
|
591
1895
|
return { code: 0, lines: [formatRecentRuns(runs)] };
|
|
592
1896
|
}
|
|
@@ -596,36 +1900,81 @@ export async function exportFlow(workflowRef, opts, deps) {
|
|
|
596
1900
|
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
597
1901
|
}
|
|
598
1902
|
catch (e) {
|
|
599
|
-
return
|
|
1903
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
600
1904
|
}
|
|
601
|
-
const
|
|
1905
|
+
const versionParam = opts.version !== undefined ? `&version=${encodeURIComponent(String(opts.version))}` : "";
|
|
1906
|
+
const res = await deps.get(`${EXPORT_PATH}?id=${encodeURIComponent(workflowId)}${versionParam}`);
|
|
602
1907
|
if (!res.ok)
|
|
603
1908
|
return asErrorResult(res, false);
|
|
604
|
-
const doc =
|
|
1909
|
+
const doc = opts.json
|
|
1910
|
+
? JSON.stringify(res.data, null, 2)
|
|
1911
|
+
: workflowToYaml(res.data);
|
|
605
1912
|
if (opts.out) {
|
|
606
|
-
|
|
1913
|
+
const text = doc.endsWith("\n") ? doc : `${doc}\n`;
|
|
1914
|
+
deps.writeFile(opts.out, text);
|
|
607
1915
|
return { code: 0, lines: [`Wrote workflow contract to ${opts.out}.`] };
|
|
608
1916
|
}
|
|
609
|
-
return { code: 0, lines: [doc] };
|
|
1917
|
+
return { code: 0, lines: [doc.endsWith("\n") ? doc.slice(0, -1) : doc] };
|
|
610
1918
|
}
|
|
611
|
-
export
|
|
1919
|
+
export function parseVersionFlag(flags) {
|
|
1920
|
+
const raw = flags["version"];
|
|
1921
|
+
if (raw === undefined)
|
|
1922
|
+
return undefined;
|
|
1923
|
+
if (typeof raw !== "string")
|
|
1924
|
+
throw new Error("--version requires a positive integer");
|
|
1925
|
+
const n = Number(raw);
|
|
1926
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
1927
|
+
throw new Error(`--version must be a positive integer (got "${raw}")`);
|
|
1928
|
+
}
|
|
1929
|
+
return n;
|
|
1930
|
+
}
|
|
1931
|
+
export async function versionsFlow(workflowRef, opts, deps, channel = getChannel()) {
|
|
1932
|
+
let workflowId;
|
|
1933
|
+
try {
|
|
1934
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
1935
|
+
}
|
|
1936
|
+
catch (e) {
|
|
1937
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
1938
|
+
}
|
|
1939
|
+
const res = await deps.get(`${VERSIONS_PATH}?id=${encodeURIComponent(workflowId)}`);
|
|
1940
|
+
const unsupported = missingRouteLine(res, "workflow versions", channel);
|
|
1941
|
+
if (unsupported)
|
|
1942
|
+
return { code: 1, lines: [unsupported] };
|
|
1943
|
+
if (!res.ok)
|
|
1944
|
+
return asErrorResult(res, opts.json);
|
|
1945
|
+
if (opts.json)
|
|
1946
|
+
return { code: 0, lines: [JSON.stringify(res.data)] };
|
|
1947
|
+
const body = res.data;
|
|
1948
|
+
const versions = Array.isArray(body)
|
|
1949
|
+
? body
|
|
1950
|
+
: (body.versions ?? []);
|
|
1951
|
+
return { code: 0, lines: [formatWorkflowVersions(versions)] };
|
|
1952
|
+
}
|
|
1953
|
+
function buildImportBody(file, opts, deps) {
|
|
612
1954
|
let text;
|
|
613
1955
|
try {
|
|
614
1956
|
text = deps.readFile(file);
|
|
615
1957
|
}
|
|
616
1958
|
catch {
|
|
617
|
-
return { code: 1, lines: [`Error: file not found: ${file}`] };
|
|
1959
|
+
return { error: { code: 1, lines: [`Error: file not found: ${file}`] } };
|
|
618
1960
|
}
|
|
619
1961
|
let parsed;
|
|
620
1962
|
try {
|
|
621
|
-
parsed =
|
|
1963
|
+
parsed = parseWorkflowText(text);
|
|
622
1964
|
}
|
|
623
1965
|
catch (e) {
|
|
624
|
-
const
|
|
625
|
-
|
|
1966
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
1967
|
+
const detail = raw.replace(/^workflow file is not valid YAML or JSON:?\s*/, "");
|
|
1968
|
+
const suffix = detail ? `: ${detail}` : "";
|
|
1969
|
+
return { error: { code: 1, lines: [`Error: ${file} is not valid YAML or JSON${suffix}`] } };
|
|
626
1970
|
}
|
|
627
1971
|
if (!isRecord(parsed)) {
|
|
628
|
-
return {
|
|
1972
|
+
return {
|
|
1973
|
+
error: {
|
|
1974
|
+
code: 1,
|
|
1975
|
+
lines: [`Error: ${file} is not a workflow contract (expected a JSON object).`],
|
|
1976
|
+
},
|
|
1977
|
+
};
|
|
629
1978
|
}
|
|
630
1979
|
const body = { ...parsed };
|
|
631
1980
|
delete body.dryRun;
|
|
@@ -637,17 +1986,25 @@ export async function importFlow(file, opts, deps) {
|
|
|
637
1986
|
const updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined;
|
|
638
1987
|
if (!updatedAt) {
|
|
639
1988
|
return {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
`
|
|
644
|
-
|
|
1989
|
+
error: {
|
|
1990
|
+
code: 1,
|
|
1991
|
+
lines: [
|
|
1992
|
+
`Error: ${file} has no "updatedAt" anchor, so drift can't be detected. ` +
|
|
1993
|
+
`Re-export the workflow first (exodus workflow export <id> --out ${file}) and retry --update.`,
|
|
1994
|
+
],
|
|
1995
|
+
},
|
|
645
1996
|
};
|
|
646
1997
|
}
|
|
647
1998
|
body.targetWorkflowId = opts.update;
|
|
648
1999
|
body.expectedUpdatedAt = updatedAt;
|
|
649
2000
|
}
|
|
650
|
-
|
|
2001
|
+
return { body };
|
|
2002
|
+
}
|
|
2003
|
+
async function runImport(file, opts, deps) {
|
|
2004
|
+
const built = buildImportBody(file, { dryRun: opts.dryRun, update: opts.update }, deps);
|
|
2005
|
+
if ("error" in built)
|
|
2006
|
+
return built.error;
|
|
2007
|
+
const res = await deps.post(IMPORT_PATH, built.body);
|
|
651
2008
|
if (!res.ok) {
|
|
652
2009
|
return {
|
|
653
2010
|
code: 1,
|
|
@@ -661,9 +2018,737 @@ export async function importFlow(file, opts, deps) {
|
|
|
661
2018
|
code: 0,
|
|
662
2019
|
lines: opts.json
|
|
663
2020
|
? [JSON.stringify(data)]
|
|
664
|
-
: [
|
|
2021
|
+
: [
|
|
2022
|
+
formatImportSummary(data, {
|
|
2023
|
+
dryRun: opts.dryRun,
|
|
2024
|
+
update: !!opts.update,
|
|
2025
|
+
validate: opts.validate,
|
|
2026
|
+
}),
|
|
2027
|
+
],
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
export function triggerExpect(t) {
|
|
2031
|
+
return t.type === "event"
|
|
2032
|
+
? { type: "event", event: t.event }
|
|
2033
|
+
: { type: "cron", cron: t.cron };
|
|
2034
|
+
}
|
|
2035
|
+
function triggerDetail(t) {
|
|
2036
|
+
return t.type === "event" ? t.event : t.cron;
|
|
2037
|
+
}
|
|
2038
|
+
const NO_TRIGGERS = "no triggers — add them via `exodus workflow export` / `import`";
|
|
2039
|
+
export function formatTriggers(triggers) {
|
|
2040
|
+
if (triggers.length === 0)
|
|
2041
|
+
return NO_TRIGGERS;
|
|
2042
|
+
return triggers
|
|
2043
|
+
.map((t, i) => {
|
|
2044
|
+
const state = t.enabled ? "enabled" : "disabled";
|
|
2045
|
+
return `${i + 1} · ${t.type} · ${triggerDetail(t)} · ${state}`;
|
|
2046
|
+
})
|
|
2047
|
+
.join("\n");
|
|
2048
|
+
}
|
|
2049
|
+
function triggerErrorResult(res, verb, json) {
|
|
2050
|
+
const missing = missingRouteLine(res, verb);
|
|
2051
|
+
if (missing) {
|
|
2052
|
+
return {
|
|
2053
|
+
code: 1,
|
|
2054
|
+
lines: json
|
|
2055
|
+
? [JSON.stringify({ ok: false, status: res.status, error: missing })]
|
|
2056
|
+
: [missing],
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
return asErrorResult(res, json);
|
|
2060
|
+
}
|
|
2061
|
+
async function fetchTriggers(workflowId, deps) {
|
|
2062
|
+
const res = await deps.get(`${EXPORT_PATH}?id=${encodeURIComponent(workflowId)}`);
|
|
2063
|
+
if (!res.ok)
|
|
2064
|
+
return { ok: false, res };
|
|
2065
|
+
const triggers = res.data.triggers ?? [];
|
|
2066
|
+
return { ok: true, triggers };
|
|
2067
|
+
}
|
|
2068
|
+
export async function triggersListFlow(workflowRef, opts, deps) {
|
|
2069
|
+
let workflowId;
|
|
2070
|
+
try {
|
|
2071
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
2072
|
+
}
|
|
2073
|
+
catch (e) {
|
|
2074
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
2075
|
+
}
|
|
2076
|
+
const fetched = await fetchTriggers(workflowId, deps);
|
|
2077
|
+
if (!fetched.ok)
|
|
2078
|
+
return triggerErrorResult(fetched.res, "workflow triggers", opts.json);
|
|
2079
|
+
if (opts.json) {
|
|
2080
|
+
return {
|
|
2081
|
+
code: 0,
|
|
2082
|
+
lines: [JSON.stringify(fetched.triggers.map((t, i) => ({ n: i + 1, ...t })))],
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
return { code: 0, lines: [formatTriggers(fetched.triggers)] };
|
|
2086
|
+
}
|
|
2087
|
+
function triggerIndexError(message, triggers, json) {
|
|
2088
|
+
if (json) {
|
|
2089
|
+
return {
|
|
2090
|
+
code: 1,
|
|
2091
|
+
lines: [JSON.stringify({ ok: false, error: message, triggers: triggers.map((t, i) => ({ n: i + 1, ...t })) })],
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
return { code: 1, lines: [message, "", formatTriggers(triggers)] };
|
|
2095
|
+
}
|
|
2096
|
+
export async function triggersSetEnabledFlow(workflowRef, n, enabled, opts, deps) {
|
|
2097
|
+
const verb = `workflow triggers ${enabled ? "enable" : "disable"}`;
|
|
2098
|
+
let workflowId;
|
|
2099
|
+
try {
|
|
2100
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
2101
|
+
}
|
|
2102
|
+
catch (e) {
|
|
2103
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
2104
|
+
}
|
|
2105
|
+
const fetched = await fetchTriggers(workflowId, deps);
|
|
2106
|
+
if (!fetched.ok)
|
|
2107
|
+
return triggerErrorResult(fetched.res, verb, opts.json);
|
|
2108
|
+
const triggers = fetched.triggers;
|
|
2109
|
+
const idx = n - 1;
|
|
2110
|
+
if (idx < 0 || idx >= triggers.length) {
|
|
2111
|
+
return triggerIndexError(`Trigger ${n} is out of range — this workflow has ${triggers.length} trigger(s).`, triggers, opts.json);
|
|
2112
|
+
}
|
|
2113
|
+
const res = await deps.post(TRIGGERS_SET_ENABLED_PATH, {
|
|
2114
|
+
workflowId,
|
|
2115
|
+
triggerIndex: idx,
|
|
2116
|
+
enabled,
|
|
2117
|
+
expect: triggerExpect(triggers[idx]),
|
|
2118
|
+
});
|
|
2119
|
+
if (!res.ok)
|
|
2120
|
+
return triggerErrorResult(res, verb, opts.json);
|
|
2121
|
+
if (opts.json)
|
|
2122
|
+
return { code: 0, lines: [JSON.stringify(res.data)] };
|
|
2123
|
+
return { code: 0, lines: [`Trigger ${n} ${enabled ? "enabled" : "disabled"}.`] };
|
|
2124
|
+
}
|
|
2125
|
+
export async function triggersFireFlow(workflowRef, opts, deps) {
|
|
2126
|
+
const verb = "workflow triggers fire";
|
|
2127
|
+
let workflowId;
|
|
2128
|
+
try {
|
|
2129
|
+
workflowId = await resolveWorkflowId(workflowRef, deps);
|
|
2130
|
+
}
|
|
2131
|
+
catch (e) {
|
|
2132
|
+
return resolveIdErrorResult(e, opts.json ?? false);
|
|
2133
|
+
}
|
|
2134
|
+
const fetched = await fetchTriggers(workflowId, deps);
|
|
2135
|
+
if (!fetched.ok)
|
|
2136
|
+
return triggerErrorResult(fetched.res, verb, opts.json);
|
|
2137
|
+
const triggers = fetched.triggers;
|
|
2138
|
+
if (triggers.length === 0) {
|
|
2139
|
+
return { code: 1, lines: [`This workflow has no triggers. ${NO_TRIGGERS}`] };
|
|
2140
|
+
}
|
|
2141
|
+
let idx;
|
|
2142
|
+
if (opts.n !== undefined) {
|
|
2143
|
+
idx = opts.n - 1;
|
|
2144
|
+
if (idx < 0 || idx >= triggers.length) {
|
|
2145
|
+
return triggerIndexError(`Trigger ${opts.n} is out of range — this workflow has ${triggers.length} trigger(s).`, triggers, opts.json);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
else if (triggers.length === 1) {
|
|
2149
|
+
idx = 0;
|
|
2150
|
+
}
|
|
2151
|
+
else {
|
|
2152
|
+
return triggerIndexError(`This workflow has ${triggers.length} triggers — specify which one to fire (e.g. \`fire 1\`).`, triggers, opts.json);
|
|
2153
|
+
}
|
|
2154
|
+
const t = triggers[idx];
|
|
2155
|
+
const n = idx + 1;
|
|
2156
|
+
if (t.type === "event" && (opts.text === undefined || opts.text === "")) {
|
|
2157
|
+
return {
|
|
2158
|
+
code: 1,
|
|
2159
|
+
lines: [
|
|
2160
|
+
`Trigger ${n} fires on the "${t.event}" event — pass --text with the input this run should carry.`,
|
|
2161
|
+
],
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
if (t.type === "cron" && opts.text !== undefined) {
|
|
2165
|
+
return {
|
|
2166
|
+
code: 1,
|
|
2167
|
+
lines: [`Trigger ${n} is a cron trigger ("${t.cron}") — it takes no --text.`],
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
const res = await deps.post(TRIGGERS_FIRE_PATH, {
|
|
2171
|
+
workflowId,
|
|
2172
|
+
triggerIndex: idx,
|
|
2173
|
+
expect: triggerExpect(t),
|
|
2174
|
+
...(opts.text !== undefined ? { text: opts.text } : {}),
|
|
2175
|
+
...(opts.imageRigOverrides
|
|
2176
|
+
? { imageRigOverrides: opts.imageRigOverrides }
|
|
2177
|
+
: {}),
|
|
2178
|
+
});
|
|
2179
|
+
if (!res.ok)
|
|
2180
|
+
return triggerErrorResult(res, verb, opts.json);
|
|
2181
|
+
const runId = res.data.runId;
|
|
2182
|
+
const base = { runId, workflowId };
|
|
2183
|
+
if (!opts.wait) {
|
|
2184
|
+
if (opts.json)
|
|
2185
|
+
return { code: 0, lines: [JSON.stringify(base)] };
|
|
2186
|
+
return {
|
|
2187
|
+
code: 0,
|
|
2188
|
+
lines: [
|
|
2189
|
+
`Trigger ${n} fired.`,
|
|
2190
|
+
`runId: ${runId}`,
|
|
2191
|
+
"This run executes as the workflow OWNER in the background.",
|
|
2192
|
+
`Poll: exodus workflow status --id ${runId}`,
|
|
2193
|
+
],
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
const startLines = [
|
|
2197
|
+
`Trigger ${n} fired.`,
|
|
2198
|
+
`runId: ${runId}`,
|
|
2199
|
+
"This run executes as the workflow OWNER in the background.",
|
|
2200
|
+
`Poll: exodus workflow status --id ${runId}`,
|
|
2201
|
+
];
|
|
2202
|
+
if (!opts.json && opts.onProgressLine) {
|
|
2203
|
+
for (const line of startLines)
|
|
2204
|
+
opts.onProgressLine(line);
|
|
2205
|
+
}
|
|
2206
|
+
const waited = await waitForRun(runId, { json: opts.json, onProgressLine: opts.onProgressLine, jsonBase: base }, deps);
|
|
2207
|
+
if (opts.json)
|
|
2208
|
+
return waited;
|
|
2209
|
+
const prefix = opts.onProgressLine ? [] : startLines;
|
|
2210
|
+
return { code: waited.code, lines: [...prefix, ...waited.lines] };
|
|
2211
|
+
}
|
|
2212
|
+
export async function importFlow(file, opts, deps) {
|
|
2213
|
+
return runImport(file, opts, deps);
|
|
2214
|
+
}
|
|
2215
|
+
export async function validateFlow(file, opts, deps) {
|
|
2216
|
+
return runImport(file, { dryRun: true, json: opts.json, update: opts.update, validate: true }, deps);
|
|
2217
|
+
}
|
|
2218
|
+
export async function templatesListFlow(json, deps) {
|
|
2219
|
+
const res = await deps.get(TEMPLATES_PATH);
|
|
2220
|
+
if (!res.ok) {
|
|
2221
|
+
const missing = missingRouteLine(res, "workflow templates");
|
|
2222
|
+
if (missing)
|
|
2223
|
+
return { code: 1, lines: [missing] };
|
|
2224
|
+
return asErrorResult(res, json);
|
|
2225
|
+
}
|
|
2226
|
+
const data = res.data;
|
|
2227
|
+
if (json)
|
|
2228
|
+
return { code: 0, lines: [JSON.stringify(data)] };
|
|
2229
|
+
return { code: 0, lines: [formatTemplatesList(data.templates ?? [])] };
|
|
2230
|
+
}
|
|
2231
|
+
export async function templatesExportFlow(key, opts, deps) {
|
|
2232
|
+
const query = opts.json
|
|
2233
|
+
? `?key=${encodeURIComponent(key)}&format=json`
|
|
2234
|
+
: `?key=${encodeURIComponent(key)}`;
|
|
2235
|
+
const res = await deps.getText(`${TEMPLATES_PATH}${query}`);
|
|
2236
|
+
if (!res.ok) {
|
|
2237
|
+
let parsedBody = res.data;
|
|
2238
|
+
try {
|
|
2239
|
+
parsedBody = JSON.parse(res.data);
|
|
2240
|
+
}
|
|
2241
|
+
catch {
|
|
2242
|
+
}
|
|
2243
|
+
const missing = missingRouteLine({ ...res, data: parsedBody }, "workflow templates export");
|
|
2244
|
+
if (missing)
|
|
2245
|
+
return { code: 1, lines: [missing] };
|
|
2246
|
+
return { code: 1, lines: [formatTextError(res)] };
|
|
2247
|
+
}
|
|
2248
|
+
const doc = res.data;
|
|
2249
|
+
if (opts.out) {
|
|
2250
|
+
const text = doc.endsWith("\n") ? doc : `${doc}\n`;
|
|
2251
|
+
deps.writeFile(opts.out, text);
|
|
2252
|
+
return { code: 0, lines: [`Wrote template "${key}" to ${opts.out}.`] };
|
|
2253
|
+
}
|
|
2254
|
+
return { code: 0, lines: [doc.endsWith("\n") ? doc.slice(0, -1) : doc] };
|
|
2255
|
+
}
|
|
2256
|
+
function formatTextError(res) {
|
|
2257
|
+
const body = res.data;
|
|
2258
|
+
let parsed;
|
|
2259
|
+
try {
|
|
2260
|
+
parsed = JSON.parse(body);
|
|
2261
|
+
}
|
|
2262
|
+
catch {
|
|
2263
|
+
const snippet = body.replace(/\s+/g, " ").trim().slice(0, 300);
|
|
2264
|
+
return formatApiError({ ok: false, status: res.status, data: snippet || "(empty response)" });
|
|
2265
|
+
}
|
|
2266
|
+
return formatApiError({ ok: false, status: res.status, data: parsed });
|
|
2267
|
+
}
|
|
2268
|
+
export async function schemaFlow(opts, deps) {
|
|
2269
|
+
const res = await deps.get(SCHEMA_PATH);
|
|
2270
|
+
if (!res.ok) {
|
|
2271
|
+
const missing = missingRouteLine(res, "workflow schema");
|
|
2272
|
+
if (missing)
|
|
2273
|
+
return { code: 1, lines: [missing] };
|
|
2274
|
+
return asErrorResult(res, opts.json);
|
|
2275
|
+
}
|
|
2276
|
+
if (opts.json)
|
|
2277
|
+
return { code: 0, lines: [JSON.stringify(res.data)] };
|
|
2278
|
+
const payload = isRecord(res.data) ? res.data : {};
|
|
2279
|
+
if (opts.kind !== undefined) {
|
|
2280
|
+
return formatSchemaFilter(payload, ["nodeKinds"], "kind", opts.kind);
|
|
2281
|
+
}
|
|
2282
|
+
if (opts.face !== undefined) {
|
|
2283
|
+
return formatSchemaFilter(payload, ["transformFaces"], "face", opts.face);
|
|
2284
|
+
}
|
|
2285
|
+
return { code: 0, lines: [formatSchema(payload)] };
|
|
2286
|
+
}
|
|
2287
|
+
export function formatAge(value, now = Date.now()) {
|
|
2288
|
+
if (value === undefined || value === null)
|
|
2289
|
+
return "-";
|
|
2290
|
+
const t = typeof value === "number" ? value : Date.parse(value);
|
|
2291
|
+
if (Number.isNaN(t))
|
|
2292
|
+
return "-";
|
|
2293
|
+
const sec = Math.max(0, Math.floor((now - t) / 1000));
|
|
2294
|
+
if (sec < 60)
|
|
2295
|
+
return `${sec}s`;
|
|
2296
|
+
const min = Math.floor(sec / 60);
|
|
2297
|
+
if (min < 60)
|
|
2298
|
+
return `${min}m`;
|
|
2299
|
+
const hr = Math.floor(min / 60);
|
|
2300
|
+
if (hr < 24)
|
|
2301
|
+
return `${hr}h`;
|
|
2302
|
+
return `${Math.floor(hr / 24)}d`;
|
|
2303
|
+
}
|
|
2304
|
+
export function parkBadge(pauseReason) {
|
|
2305
|
+
if (pauseReason === "taste")
|
|
2306
|
+
return "gate";
|
|
2307
|
+
if (pauseReason === "repair")
|
|
2308
|
+
return "repair";
|
|
2309
|
+
if (pauseReason === "slots")
|
|
2310
|
+
return "slots";
|
|
2311
|
+
if (pauseReason === "checkpoint")
|
|
2312
|
+
return "checkpoint";
|
|
2313
|
+
return "legacy";
|
|
2314
|
+
}
|
|
2315
|
+
export function invocationBadge(row) {
|
|
2316
|
+
if (row.triggeredBy)
|
|
2317
|
+
return `trig:${row.triggeredBy.event ?? row.triggeredBy.type}`;
|
|
2318
|
+
if (row.invocationMode === "background")
|
|
2319
|
+
return "bg";
|
|
2320
|
+
return "";
|
|
2321
|
+
}
|
|
2322
|
+
const NO_INBOX = "Nothing waiting on you — the review inbox is empty.";
|
|
2323
|
+
export function formatInbox(rows, now = Date.now()) {
|
|
2324
|
+
if (rows.length === 0)
|
|
2325
|
+
return NO_INBOX;
|
|
2326
|
+
return table(["run", "workflow", "kind", "node", "via", "age"], rows.map((r) => [
|
|
2327
|
+
r._id,
|
|
2328
|
+
r.workflowName || "(unnamed)",
|
|
2329
|
+
parkBadge(r.pauseReason),
|
|
2330
|
+
r.pausedNodeId ?? "-",
|
|
2331
|
+
invocationBadge(r) || "-",
|
|
2332
|
+
formatAge(r.createdAt, now),
|
|
2333
|
+
]));
|
|
2334
|
+
}
|
|
2335
|
+
export async function inboxFlow(json, deps) {
|
|
2336
|
+
const res = await deps.get(INBOX_PATH);
|
|
2337
|
+
if (!res.ok)
|
|
2338
|
+
return triggerErrorResult(res, "workflow inbox", json);
|
|
2339
|
+
const data = res.data;
|
|
2340
|
+
const rows = data.runs ?? [];
|
|
2341
|
+
return { code: 0, lines: json ? [JSON.stringify(rows)] : [formatInbox(rows)] };
|
|
2342
|
+
}
|
|
2343
|
+
function errLine(message, json, status) {
|
|
2344
|
+
return {
|
|
2345
|
+
code: 1,
|
|
2346
|
+
lines: [
|
|
2347
|
+
json
|
|
2348
|
+
? JSON.stringify({ ok: false, ...(status ? { status } : {}), error: message })
|
|
2349
|
+
: message,
|
|
2350
|
+
],
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
function okLine(message, payload, json) {
|
|
2354
|
+
return { code: 0, lines: [json ? JSON.stringify(payload) : message] };
|
|
2355
|
+
}
|
|
2356
|
+
function asWorkflowRun(data) {
|
|
2357
|
+
return isRecord(data) && typeof data["_id"] === "string"
|
|
2358
|
+
? data
|
|
2359
|
+
: undefined;
|
|
2360
|
+
}
|
|
2361
|
+
function describePark(run) {
|
|
2362
|
+
if (normalizeRunStatus(run.status) !== "awaiting-approval") {
|
|
2363
|
+
return `status: ${runStatusLabel(run.status)}`;
|
|
2364
|
+
}
|
|
2365
|
+
switch (run.pauseReason) {
|
|
2366
|
+
case "taste":
|
|
2367
|
+
return "parked at a Gate box, which retired in 2.0 (cancel and re-run)";
|
|
2368
|
+
case "repair":
|
|
2369
|
+
return "parked for repair";
|
|
2370
|
+
case "slots":
|
|
2371
|
+
return "parked for slot answers";
|
|
2372
|
+
case "call":
|
|
2373
|
+
return "parked on a child workflow";
|
|
2374
|
+
case "checkpoint":
|
|
2375
|
+
return `parked at a checkpoint (use: exodus workflow checkpoint ${run._id})`;
|
|
2376
|
+
default:
|
|
2377
|
+
return "parked at the cost gate (legacy)";
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
const PARK_LABEL = {
|
|
2381
|
+
taste: "a retired Gate review",
|
|
2382
|
+
repair: "a repair",
|
|
2383
|
+
slots: "slot answers",
|
|
2384
|
+
call: "a child workflow",
|
|
2385
|
+
checkpoint: "a checkpoint approval",
|
|
2386
|
+
};
|
|
2387
|
+
async function preflightPark(runId, expected, verb, json, deps) {
|
|
2388
|
+
const res = await deps.get(`${STATUS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2389
|
+
if (!res.ok)
|
|
2390
|
+
return { ok: false, result: triggerErrorResult(res, verb, json) };
|
|
2391
|
+
const run = asWorkflowRun(res.data);
|
|
2392
|
+
if (!run) {
|
|
2393
|
+
return { ok: false, result: errLine(`Could not read run ${runId}.`, json) };
|
|
2394
|
+
}
|
|
2395
|
+
const allowed = Array.isArray(expected)
|
|
2396
|
+
? expected
|
|
2397
|
+
: [expected];
|
|
2398
|
+
if (normalizeRunStatus(run.status) !== "awaiting-approval" ||
|
|
2399
|
+
run.pauseReason === undefined ||
|
|
2400
|
+
!allowed.includes(run.pauseReason)) {
|
|
2401
|
+
return {
|
|
2402
|
+
ok: false,
|
|
2403
|
+
result: errLine(`Run ${runId} is not parked for ${PARK_LABEL[allowed[0]]} — it is ${describePark(run)}.`, json),
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
return { ok: true, run };
|
|
2407
|
+
}
|
|
2408
|
+
function checkpointOutputs(run) {
|
|
2409
|
+
const node = (run.nodes ?? []).find((x) => x.nodeId === run.pausedNodeId);
|
|
2410
|
+
if (!node)
|
|
2411
|
+
return [];
|
|
2412
|
+
const out = [];
|
|
2413
|
+
let n = 0;
|
|
2414
|
+
(node.outputs ?? []).forEach((a, idx) => {
|
|
2415
|
+
if (a.type !== "text")
|
|
2416
|
+
return;
|
|
2417
|
+
n += 1;
|
|
2418
|
+
out.push({
|
|
2419
|
+
n,
|
|
2420
|
+
outputIndex: idx,
|
|
2421
|
+
text: a.text,
|
|
2422
|
+
label: a.label,
|
|
2423
|
+
humanEdited: !!a.humanEdited,
|
|
2424
|
+
...(a.item ? { item: a.item } : {}),
|
|
2425
|
+
});
|
|
2426
|
+
});
|
|
2427
|
+
return out;
|
|
2428
|
+
}
|
|
2429
|
+
function checkpointItemIndexes(outputs) {
|
|
2430
|
+
const seen = new Set();
|
|
2431
|
+
for (const o of outputs)
|
|
2432
|
+
if (o.item)
|
|
2433
|
+
seen.add(o.item.index);
|
|
2434
|
+
return [...seen].sort((a, b) => a - b);
|
|
2435
|
+
}
|
|
2436
|
+
function checkpointNode(run) {
|
|
2437
|
+
return (run.nodes ?? []).find((x) => x.nodeId === run.pausedNodeId);
|
|
2438
|
+
}
|
|
2439
|
+
function checkpointOutputRow(o, indent) {
|
|
2440
|
+
const label = o.label ? ` [${o.label}]` : "";
|
|
2441
|
+
const edited = o.humanEdited ? " (edited)" : "";
|
|
2442
|
+
return `${indent}${o.n}.${label} ${truncateText(o.text, 200)}${edited}`;
|
|
2443
|
+
}
|
|
2444
|
+
function formatCheckpointOutputs(outputs) {
|
|
2445
|
+
if (outputs.length === 0)
|
|
2446
|
+
return " (this step produced no text to review)";
|
|
2447
|
+
const indexes = checkpointItemIndexes(outputs);
|
|
2448
|
+
if (indexes.length === 0) {
|
|
2449
|
+
return outputs.map((o) => checkpointOutputRow(o, " ")).join("\n");
|
|
2450
|
+
}
|
|
2451
|
+
const lines = [];
|
|
2452
|
+
for (const index of indexes) {
|
|
2453
|
+
const rows = outputs.filter((o) => o.item?.index === index);
|
|
2454
|
+
const total = rows[0]?.item?.total ?? indexes.length;
|
|
2455
|
+
lines.push(` Item ${index + 1} of ${total}`);
|
|
2456
|
+
for (const row of rows)
|
|
2457
|
+
lines.push(checkpointOutputRow(row, " "));
|
|
2458
|
+
}
|
|
2459
|
+
const shared = outputs.filter((o) => !o.item);
|
|
2460
|
+
if (shared.length > 0) {
|
|
2461
|
+
lines.push(" Not tied to an item");
|
|
2462
|
+
for (const row of shared)
|
|
2463
|
+
lines.push(checkpointOutputRow(row, " "));
|
|
2464
|
+
}
|
|
2465
|
+
return lines.join("\n");
|
|
2466
|
+
}
|
|
2467
|
+
function checkpointRangeError(num, count, json) {
|
|
2468
|
+
const range = count > 0 ? ` (valid: 1–${count})` : "";
|
|
2469
|
+
return errLine(`Output ${num} is out of range — this step has ${count} text output${count === 1 ? "" : "s"}${range}.`, json);
|
|
2470
|
+
}
|
|
2471
|
+
export async function checkpointShowFlow(runId, opts, deps) {
|
|
2472
|
+
const pf = await preflightPark(runId, "checkpoint", "workflow checkpoint", opts.json, deps);
|
|
2473
|
+
if (!pf.ok)
|
|
2474
|
+
return pf.result;
|
|
2475
|
+
const outputs = checkpointOutputs(pf.run);
|
|
2476
|
+
const node = checkpointNode(pf.run);
|
|
2477
|
+
const itemIndexes = checkpointItemIndexes(outputs);
|
|
2478
|
+
if (opts.json) {
|
|
2479
|
+
return {
|
|
2480
|
+
code: 0,
|
|
2481
|
+
lines: [
|
|
2482
|
+
JSON.stringify({
|
|
2483
|
+
runId,
|
|
2484
|
+
pausedNodeId: pf.run.pausedNodeId,
|
|
2485
|
+
pausedNodeKind: node?.kind,
|
|
2486
|
+
...(node?.botSlug ? { botSlug: node.botSlug } : {}),
|
|
2487
|
+
...(itemIndexes.length > 0 ? { itemIndexes } : {}),
|
|
2488
|
+
outputs: outputs.map((o) => ({
|
|
2489
|
+
n: o.n,
|
|
2490
|
+
text: o.text,
|
|
2491
|
+
...(o.label ? { label: o.label } : {}),
|
|
2492
|
+
humanEdited: o.humanEdited,
|
|
2493
|
+
...(o.item ? { item: o.item } : {}),
|
|
2494
|
+
})),
|
|
2495
|
+
}),
|
|
2496
|
+
],
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
const stepLabel = node
|
|
2500
|
+
? `${node.nodeId} (${node.botSlug ? `${node.kind}: ${node.botSlug}` : node.kind})`
|
|
2501
|
+
: (pf.run.pausedNodeId ?? "-");
|
|
2502
|
+
const itemsNote = itemIndexes.length > 0
|
|
2503
|
+
? [
|
|
2504
|
+
`This step ran once per item (${itemIndexes.length} of them). Approve keeps every item;`,
|
|
2505
|
+
"reject drops the ones you name and the run carries on with the rest.",
|
|
2506
|
+
"",
|
|
2507
|
+
]
|
|
2508
|
+
: [];
|
|
2509
|
+
const rejectLine = itemIndexes.length > 0
|
|
2510
|
+
? [`Reject: exodus workflow checkpoint ${runId} approve --reject 2 --wait`]
|
|
2511
|
+
: [];
|
|
2512
|
+
return {
|
|
2513
|
+
code: 0,
|
|
2514
|
+
lines: [
|
|
2515
|
+
`Checkpoint — ${pf.run.workflowName}`,
|
|
2516
|
+
`runId: ${runId}`,
|
|
2517
|
+
`step: ${stepLabel}`,
|
|
2518
|
+
"",
|
|
2519
|
+
"The run is holding at this checkpoint until you say go.",
|
|
2520
|
+
"",
|
|
2521
|
+
...itemsNote,
|
|
2522
|
+
`Its output (${outputs.length}):`,
|
|
2523
|
+
formatCheckpointOutputs(outputs),
|
|
2524
|
+
"",
|
|
2525
|
+
`Approve: exodus workflow checkpoint ${runId} approve --wait`,
|
|
2526
|
+
...rejectLine,
|
|
2527
|
+
`Edit: exodus workflow checkpoint ${runId} edit 1 --text "..."`,
|
|
2528
|
+
`Redo: exodus workflow checkpoint ${runId} retry --wait`,
|
|
2529
|
+
`Cancel: exodus workflow checkpoint ${runId} cancel --reason "..."`,
|
|
2530
|
+
],
|
|
665
2531
|
};
|
|
666
2532
|
}
|
|
2533
|
+
export function parseRejectItems(raw) {
|
|
2534
|
+
const bad = (why) => ({
|
|
2535
|
+
ok: false,
|
|
2536
|
+
message: `${why} --reject takes the item numbers you see in "checkpoint show", like --reject 2 or --reject 2,5.`,
|
|
2537
|
+
});
|
|
2538
|
+
if (raw === undefined || raw.trim() === "")
|
|
2539
|
+
return bad("--reject needs at least one item number.");
|
|
2540
|
+
const parts = raw
|
|
2541
|
+
.split(",")
|
|
2542
|
+
.map((s) => s.trim())
|
|
2543
|
+
.filter((s) => s.length > 0);
|
|
2544
|
+
if (parts.length === 0)
|
|
2545
|
+
return bad("--reject needs at least one item number.");
|
|
2546
|
+
const items = [];
|
|
2547
|
+
for (const part of parts) {
|
|
2548
|
+
if (!/^\d+$/.test(part))
|
|
2549
|
+
return bad(`"${part}" isn't an item number.`);
|
|
2550
|
+
const n = Number(part);
|
|
2551
|
+
if (n < 1)
|
|
2552
|
+
return bad("Items are numbered from 1.");
|
|
2553
|
+
if (!items.includes(n))
|
|
2554
|
+
items.push(n);
|
|
2555
|
+
}
|
|
2556
|
+
return { ok: true, items: items.sort((a, b) => a - b) };
|
|
2557
|
+
}
|
|
2558
|
+
export async function checkpointApproveFlow(runId, opts, deps) {
|
|
2559
|
+
let rejectItems;
|
|
2560
|
+
if (opts.reject !== undefined) {
|
|
2561
|
+
const parsed = parseRejectItems(opts.reject);
|
|
2562
|
+
if (!parsed.ok)
|
|
2563
|
+
return errLine(parsed.message, opts.json);
|
|
2564
|
+
rejectItems = parsed.items;
|
|
2565
|
+
}
|
|
2566
|
+
const pf = await preflightPark(runId, "checkpoint", "workflow checkpoint approve", opts.json, deps);
|
|
2567
|
+
if (!pf.ok)
|
|
2568
|
+
return pf.result;
|
|
2569
|
+
if (rejectItems === undefined) {
|
|
2570
|
+
const res = await deps.post(APPROVE_PATH, { runId });
|
|
2571
|
+
if (!res.ok)
|
|
2572
|
+
return triggerErrorResult(res, "workflow checkpoint approve", opts.json);
|
|
2573
|
+
const triggerRunId = res.data.triggerRunId;
|
|
2574
|
+
return resumeAndMaybeWait(runId, triggerRunId, ["Checkpoint approved — the run resumes."], opts, deps);
|
|
2575
|
+
}
|
|
2576
|
+
const rejections = rejectItems.map((n) => n - 1);
|
|
2577
|
+
const res = await deps.post(CHECKPOINT_RESOLVE_ITEMS_PATH, { runId, rejections });
|
|
2578
|
+
if (!res.ok)
|
|
2579
|
+
return triggerErrorResult(res, "workflow checkpoint approve", opts.json);
|
|
2580
|
+
const data = (res.data ?? {});
|
|
2581
|
+
const rejected = (data.rejected ?? rejections).map((i) => i + 1).sort((a, b) => a - b);
|
|
2582
|
+
const kept = data.remaining ?? 0;
|
|
2583
|
+
const total = kept + rejected.length;
|
|
2584
|
+
return resumeAndMaybeWait(runId, data.triggerRunId, [`Approved — kept ${kept} of ${total} items (rejected: ${rejected.join(", ")}).`], opts, deps, undefined, { rejected, remaining: kept });
|
|
2585
|
+
}
|
|
2586
|
+
export async function checkpointEditFlow(runId, n, sources, opts, deps) {
|
|
2587
|
+
const provided = [sources.text, sources.file, sources.stdin].filter((x) => x !== undefined);
|
|
2588
|
+
if (provided.length === 0) {
|
|
2589
|
+
return errLine("Provide the replacement text via one of --text, --file <path>, or piped stdin.", opts.json);
|
|
2590
|
+
}
|
|
2591
|
+
if (provided.length > 1) {
|
|
2592
|
+
return errLine("Provide the replacement text via exactly one of --text, --file, or stdin — not several.", opts.json);
|
|
2593
|
+
}
|
|
2594
|
+
let text;
|
|
2595
|
+
if (sources.text !== undefined) {
|
|
2596
|
+
text = sources.text;
|
|
2597
|
+
}
|
|
2598
|
+
else if (sources.file !== undefined) {
|
|
2599
|
+
try {
|
|
2600
|
+
text = deps.readFile(sources.file);
|
|
2601
|
+
}
|
|
2602
|
+
catch (e) {
|
|
2603
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2604
|
+
return errLine(`Could not read file "${sources.file}": ${msg}`, opts.json);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
else {
|
|
2608
|
+
text = sources.stdin;
|
|
2609
|
+
}
|
|
2610
|
+
const pf = await preflightPark(runId, "checkpoint", "workflow checkpoint edit", opts.json, deps);
|
|
2611
|
+
if (!pf.ok)
|
|
2612
|
+
return pf.result;
|
|
2613
|
+
const outputs = checkpointOutputs(pf.run);
|
|
2614
|
+
const target = outputs.find((o) => o.n === n);
|
|
2615
|
+
if (!target)
|
|
2616
|
+
return checkpointRangeError(n, outputs.length, opts.json);
|
|
2617
|
+
const res = await deps.post(CHECKPOINT_EDIT_PATH, {
|
|
2618
|
+
runId,
|
|
2619
|
+
nodeId: pf.run.pausedNodeId,
|
|
2620
|
+
outputIndex: target.outputIndex,
|
|
2621
|
+
text,
|
|
2622
|
+
});
|
|
2623
|
+
if (!res.ok)
|
|
2624
|
+
return triggerErrorResult(res, "workflow checkpoint edit", opts.json);
|
|
2625
|
+
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);
|
|
2626
|
+
}
|
|
2627
|
+
export async function checkpointRetryFlow(runId, opts, deps) {
|
|
2628
|
+
const pf = await preflightPark(runId, "checkpoint", "workflow checkpoint retry", opts.json, deps);
|
|
2629
|
+
if (!pf.ok)
|
|
2630
|
+
return pf.result;
|
|
2631
|
+
const note = opts.note?.trim();
|
|
2632
|
+
const res = await deps.post(CHECKPOINT_RETRY_PATH, {
|
|
2633
|
+
runId,
|
|
2634
|
+
...(note ? { note } : {}),
|
|
2635
|
+
});
|
|
2636
|
+
if (!res.ok)
|
|
2637
|
+
return triggerErrorResult(res, "workflow checkpoint retry", opts.json);
|
|
2638
|
+
const triggerRunId = res.data.triggerRunId;
|
|
2639
|
+
return resumeAndMaybeWait(runId, triggerRunId, ["Redoing the step that feeds this checkpoint — its old output is discarded."], opts, deps, {
|
|
2640
|
+
pauseReason: "checkpoint",
|
|
2641
|
+
headline: " ⏸ The step re-ran — its fresh output is waiting on your approval.",
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
export async function checkpointCancelFlow(runId, opts, deps) {
|
|
2645
|
+
const pf = await preflightPark(runId, ["checkpoint", "taste"], "workflow checkpoint cancel", opts.json, deps);
|
|
2646
|
+
if (!pf.ok)
|
|
2647
|
+
return pf.result;
|
|
2648
|
+
const res = await deps.post(CANCEL_PATH, {
|
|
2649
|
+
runId,
|
|
2650
|
+
...(opts.reason !== undefined ? { reason: opts.reason } : {}),
|
|
2651
|
+
});
|
|
2652
|
+
if (!res.ok)
|
|
2653
|
+
return triggerErrorResult(res, "workflow checkpoint cancel", opts.json);
|
|
2654
|
+
return okLine(`Checkpoint canceled — run ${runId} stopped.`, { ok: true, runId }, opts.json);
|
|
2655
|
+
}
|
|
2656
|
+
export async function repairFlow(runId, action, opts, deps) {
|
|
2657
|
+
const verb = `workflow repair ${action}`;
|
|
2658
|
+
const pf = await preflightPark(runId, "repair", verb, opts.json, deps);
|
|
2659
|
+
if (!pf.ok)
|
|
2660
|
+
return pf.result;
|
|
2661
|
+
if (action === "kill") {
|
|
2662
|
+
const res = await deps.post(CANCEL_PATH, { runId });
|
|
2663
|
+
if (!res.ok)
|
|
2664
|
+
return triggerErrorResult(res, verb, opts.json);
|
|
2665
|
+
return okLine(`Repair killed — run ${runId} canceled.`, { ok: true, runId }, opts.json);
|
|
2666
|
+
}
|
|
2667
|
+
const path = action === "retry" ? REPAIR_RETRY_PATH : REPAIR_SKIP_PATH;
|
|
2668
|
+
const res = await deps.post(path, { runId });
|
|
2669
|
+
if (!res.ok)
|
|
2670
|
+
return triggerErrorResult(res, verb, opts.json);
|
|
2671
|
+
const triggerRunId = res.data.triggerRunId;
|
|
2672
|
+
return resumeAndMaybeWait(runId, triggerRunId, [`Repair ${action} started — the run resumes.`], opts, deps);
|
|
2673
|
+
}
|
|
2674
|
+
async function resumeAndMaybeWait(runId, triggerRunId, headline, opts, deps, landOnPark, extraJson) {
|
|
2675
|
+
const base = { runId, triggerRunId, ...(extraJson ?? {}) };
|
|
2676
|
+
const startLines = [
|
|
2677
|
+
...headline,
|
|
2678
|
+
`runId: ${runId}`,
|
|
2679
|
+
`triggerRunId: ${triggerRunId ?? "-"}`,
|
|
2680
|
+
`Poll: exodus workflow status --id ${runId}`,
|
|
2681
|
+
];
|
|
2682
|
+
if (!opts.wait) {
|
|
2683
|
+
if (opts.json)
|
|
2684
|
+
return { code: 0, lines: [JSON.stringify(base)] };
|
|
2685
|
+
return { code: 0, lines: startLines };
|
|
2686
|
+
}
|
|
2687
|
+
if (!opts.json && opts.onProgressLine) {
|
|
2688
|
+
for (const line of startLines)
|
|
2689
|
+
opts.onProgressLine(line);
|
|
2690
|
+
}
|
|
2691
|
+
const waited = await waitForRun(runId, {
|
|
2692
|
+
json: opts.json,
|
|
2693
|
+
onProgressLine: opts.onProgressLine,
|
|
2694
|
+
jsonBase: base,
|
|
2695
|
+
...(landOnPark ? { landOnPark } : {}),
|
|
2696
|
+
}, deps);
|
|
2697
|
+
if (opts.json)
|
|
2698
|
+
return waited;
|
|
2699
|
+
const prefix = opts.onProgressLine ? [] : startLines;
|
|
2700
|
+
return { code: waited.code, lines: [...prefix, ...waited.lines] };
|
|
2701
|
+
}
|
|
2702
|
+
export function parseSlotFlags(args) {
|
|
2703
|
+
const values = {};
|
|
2704
|
+
for (let i = 0; i < args.length; i++) {
|
|
2705
|
+
const arg = args[i];
|
|
2706
|
+
let raw;
|
|
2707
|
+
if (arg === "--slot") {
|
|
2708
|
+
raw = args[i + 1];
|
|
2709
|
+
i++;
|
|
2710
|
+
}
|
|
2711
|
+
else if (arg.startsWith("--slot=")) {
|
|
2712
|
+
raw = arg.slice("--slot=".length);
|
|
2713
|
+
}
|
|
2714
|
+
else {
|
|
2715
|
+
continue;
|
|
2716
|
+
}
|
|
2717
|
+
if (!raw)
|
|
2718
|
+
throw new Error("--slot requires key=value");
|
|
2719
|
+
const eq = raw.indexOf("=");
|
|
2720
|
+
if (eq <= 0)
|
|
2721
|
+
throw new Error(`--slot must be key=value (got "${raw}")`);
|
|
2722
|
+
const key = raw.slice(0, eq).trim();
|
|
2723
|
+
if (!key)
|
|
2724
|
+
throw new Error(`--slot must include a key (got "${raw}")`);
|
|
2725
|
+
values[key] = raw.slice(eq + 1);
|
|
2726
|
+
}
|
|
2727
|
+
return values;
|
|
2728
|
+
}
|
|
2729
|
+
export async function answerFlow(runId, values, opts, deps) {
|
|
2730
|
+
const pf = await preflightPark(runId, "slots", "workflow answer", opts.json, deps);
|
|
2731
|
+
if (!pf.ok)
|
|
2732
|
+
return pf.result;
|
|
2733
|
+
const pending = pf.run.pendingSlots ?? [];
|
|
2734
|
+
if (Object.keys(values).length === 0) {
|
|
2735
|
+
if (opts.json) {
|
|
2736
|
+
return { code: 0, lines: [JSON.stringify({ runId, pendingSlots: pending })] };
|
|
2737
|
+
}
|
|
2738
|
+
const lines = [`Run ${runId} is waiting on ${pending.length} slot answer(s):`];
|
|
2739
|
+
for (const s of pending) {
|
|
2740
|
+
const label = s.label ? ` — ${s.label}` : "";
|
|
2741
|
+
const hint = s.hint ? ` (${s.hint})` : "";
|
|
2742
|
+
lines.push(` ${s.id}${label}${hint}`);
|
|
2743
|
+
}
|
|
2744
|
+
lines.push("", `Answer: exodus workflow answer ${runId} --slot ${pending[0]?.id ?? "key"}=value`);
|
|
2745
|
+
return { code: 0, lines };
|
|
2746
|
+
}
|
|
2747
|
+
const res = await deps.post(ANSWER_PATH, { runId, values });
|
|
2748
|
+
if (!res.ok)
|
|
2749
|
+
return triggerErrorResult(res, "workflow answer", opts.json);
|
|
2750
|
+
return okLine(`Answered ${Object.keys(values).length} slot(s) for run ${runId} — the child resumes.`, { ok: true, runId, values }, opts.json);
|
|
2751
|
+
}
|
|
667
2752
|
function flagString(flags, name) {
|
|
668
2753
|
const v = flags[name];
|
|
669
2754
|
return typeof v === "string" ? v : undefined;
|
|
@@ -692,6 +2777,23 @@ async function printResult(result) {
|
|
|
692
2777
|
if (result.code !== 0)
|
|
693
2778
|
process.exit(result.code);
|
|
694
2779
|
}
|
|
2780
|
+
async function readAllStdin() {
|
|
2781
|
+
const chunks = [];
|
|
2782
|
+
for await (const chunk of process.stdin)
|
|
2783
|
+
chunks.push(chunk);
|
|
2784
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
2785
|
+
}
|
|
2786
|
+
async function maybeReadStdin(flags) {
|
|
2787
|
+
if (flagString(flags, "text") !== undefined || flagString(flags, "file") !== undefined) {
|
|
2788
|
+
return undefined;
|
|
2789
|
+
}
|
|
2790
|
+
if (process.stdin.isTTY)
|
|
2791
|
+
return undefined;
|
|
2792
|
+
const raw = await readAllStdin();
|
|
2793
|
+
if (raw.length === 0)
|
|
2794
|
+
return undefined;
|
|
2795
|
+
return raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
|
2796
|
+
}
|
|
695
2797
|
export async function run(flags) {
|
|
696
2798
|
const positional = parsePositional();
|
|
697
2799
|
const [sub, ...rest] = positional;
|
|
@@ -714,16 +2816,54 @@ export async function run(flags) {
|
|
|
714
2816
|
if (sub === "bots") {
|
|
715
2817
|
return printResult(await botsFlow({ category: flagString(flags, "category"), slug: flagString(flags, "slug"), json }, defaultDeps));
|
|
716
2818
|
}
|
|
2819
|
+
if (sub === "templates") {
|
|
2820
|
+
const action = rest[0];
|
|
2821
|
+
if (!action || action === "list") {
|
|
2822
|
+
return printResult(await templatesListFlow(json, defaultDeps));
|
|
2823
|
+
}
|
|
2824
|
+
if (action === "export") {
|
|
2825
|
+
const key = rest[1];
|
|
2826
|
+
if (!key) {
|
|
2827
|
+
console.error("Error: workflow templates export requires <key>.");
|
|
2828
|
+
console.log("Usage: exodus workflow templates export <key> [--out <file>] [--json]");
|
|
2829
|
+
process.exit(1);
|
|
2830
|
+
}
|
|
2831
|
+
return printResult(await templatesExportFlow(key, { out: flagString(flags, "out"), json }, defaultDeps));
|
|
2832
|
+
}
|
|
2833
|
+
console.error(`Unknown templates action: "${action}"`);
|
|
2834
|
+
console.log("Usage: exodus workflow templates [list] | exodus workflow templates export <key>");
|
|
2835
|
+
process.exit(1);
|
|
2836
|
+
}
|
|
2837
|
+
if (sub === "schema") {
|
|
2838
|
+
return printResult(await schemaFlow({ json, kind: flagString(flags, "kind"), face: flagString(flags, "face") }, defaultDeps));
|
|
2839
|
+
}
|
|
2840
|
+
if (sub === "validate") {
|
|
2841
|
+
const file = rest[0];
|
|
2842
|
+
if (!file) {
|
|
2843
|
+
console.error("Error: workflow validate requires <file>.");
|
|
2844
|
+
console.log("Usage: exodus workflow validate <file> [--update <workflowId>] [--json]");
|
|
2845
|
+
process.exit(1);
|
|
2846
|
+
}
|
|
2847
|
+
return printResult(await validateFlow(file, { json, update: flagString(flags, "update") }, defaultDeps));
|
|
2848
|
+
}
|
|
717
2849
|
if (sub === "run") {
|
|
718
2850
|
const workflowRef = rest[0];
|
|
719
2851
|
if (!workflowRef) {
|
|
720
2852
|
console.error("Error: workflow run requires <workflowId|name>.");
|
|
721
|
-
console.log("Usage: exodus workflow run <workflowId|name> [--input key=value ...] [--wait] [--json]");
|
|
2853
|
+
console.log("Usage: exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=./path/to/file ...] [--auto-approve] [--wait] [--json]");
|
|
722
2854
|
process.exit(1);
|
|
723
2855
|
}
|
|
724
2856
|
let inputs;
|
|
2857
|
+
let terminalNodeIds;
|
|
2858
|
+
let fill;
|
|
2859
|
+
let autoApprove;
|
|
2860
|
+
let imageRigOverrides;
|
|
725
2861
|
try {
|
|
726
|
-
inputs =
|
|
2862
|
+
inputs = parseRawInputFlags(process.argv.slice(3));
|
|
2863
|
+
terminalNodeIds = parseTerminalFlags(process.argv.slice(3));
|
|
2864
|
+
fill = parseFillFlag(process.argv.slice(3));
|
|
2865
|
+
autoApprove = parseAutoApproveFlag(process.argv.slice(3));
|
|
2866
|
+
imageRigOverrides = parseRigOverridesFlag(process.argv.slice(3), defaultDeps.readFile);
|
|
727
2867
|
}
|
|
728
2868
|
catch (e) {
|
|
729
2869
|
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -731,22 +2871,45 @@ export async function run(flags) {
|
|
|
731
2871
|
}
|
|
732
2872
|
return printResult(await runFlow(workflowRef, {
|
|
733
2873
|
inputs,
|
|
2874
|
+
terminalNodeIds,
|
|
2875
|
+
fill,
|
|
2876
|
+
autoApprove,
|
|
2877
|
+
imageRigOverrides,
|
|
734
2878
|
wait: flags["wait"] === true,
|
|
735
2879
|
json,
|
|
2880
|
+
out: flagString(flags, "out"),
|
|
736
2881
|
onProgressLine: (line) => console.log(line),
|
|
2882
|
+
onWarningLine: (line) => console.error(line),
|
|
737
2883
|
}, defaultDeps));
|
|
738
2884
|
}
|
|
739
2885
|
if (sub === "status") {
|
|
740
|
-
return printResult(await statusFlow({ id: flagString(flags, "id"), json }, defaultDeps));
|
|
2886
|
+
return printResult(await statusFlow({ id: flagString(flags, "id"), json, out: flagString(flags, "out") }, defaultDeps));
|
|
2887
|
+
}
|
|
2888
|
+
if (sub === "versions") {
|
|
2889
|
+
const workflowRef = rest[0];
|
|
2890
|
+
if (!workflowRef) {
|
|
2891
|
+
console.error("Error: workflow versions requires <workflowId|name>.");
|
|
2892
|
+
console.log("Usage: exodus workflow versions <workflowId|name> [--json]");
|
|
2893
|
+
process.exit(1);
|
|
2894
|
+
}
|
|
2895
|
+
return printResult(await versionsFlow(workflowRef, { json }, defaultDeps));
|
|
741
2896
|
}
|
|
742
2897
|
if (sub === "export") {
|
|
743
2898
|
const workflowRef = rest[0];
|
|
744
2899
|
if (!workflowRef) {
|
|
745
2900
|
console.error("Error: workflow export requires <workflowId|name>.");
|
|
746
|
-
console.log("Usage: exodus workflow export <workflowId|name> [--out <file>]");
|
|
2901
|
+
console.log("Usage: exodus workflow export <workflowId|name> [--out <file>] [--version <n>]");
|
|
2902
|
+
process.exit(1);
|
|
2903
|
+
}
|
|
2904
|
+
let version;
|
|
2905
|
+
try {
|
|
2906
|
+
version = parseVersionFlag(flags);
|
|
2907
|
+
}
|
|
2908
|
+
catch (e) {
|
|
2909
|
+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
747
2910
|
process.exit(1);
|
|
748
2911
|
}
|
|
749
|
-
return printResult(await exportFlow(workflowRef, { out: flagString(flags, "out") }, defaultDeps));
|
|
2912
|
+
return printResult(await exportFlow(workflowRef, { out: flagString(flags, "out"), json, version }, defaultDeps));
|
|
750
2913
|
}
|
|
751
2914
|
if (sub === "import") {
|
|
752
2915
|
const file = rest[0];
|
|
@@ -757,7 +2920,150 @@ export async function run(flags) {
|
|
|
757
2920
|
}
|
|
758
2921
|
return printResult(await importFlow(file, { dryRun: flags["dry-run"] === true, json, update: flagString(flags, "update") }, defaultDeps));
|
|
759
2922
|
}
|
|
2923
|
+
if (sub === "inbox") {
|
|
2924
|
+
return printResult(await inboxFlow(json, defaultDeps));
|
|
2925
|
+
}
|
|
2926
|
+
if (sub === "gate") {
|
|
2927
|
+
console.error(RETIRED_GATE_VERB_POINTER);
|
|
2928
|
+
process.exit(1);
|
|
2929
|
+
}
|
|
2930
|
+
if (sub === "checkpoint") {
|
|
2931
|
+
const runId = rest[0];
|
|
2932
|
+
if (!runId) {
|
|
2933
|
+
console.error("Error: workflow checkpoint requires <runId>.");
|
|
2934
|
+
console.log('Usage: exodus workflow checkpoint <runId> [show | edit <n> | approve [--reject <n,..>] | retry | cancel]');
|
|
2935
|
+
process.exit(1);
|
|
2936
|
+
}
|
|
2937
|
+
const action = rest[1];
|
|
2938
|
+
if (!action || action === "show") {
|
|
2939
|
+
return printResult(await checkpointShowFlow(runId, { json }, defaultDeps));
|
|
2940
|
+
}
|
|
2941
|
+
if (action === "edit") {
|
|
2942
|
+
const n = parseTriggerIndex(rest[2]);
|
|
2943
|
+
if (n === undefined) {
|
|
2944
|
+
console.error("Error: checkpoint edit needs a 1-based output number <n>.");
|
|
2945
|
+
console.log(`Usage: exodus workflow checkpoint <runId> edit <n> [--text "..." | --file <path> | (stdin)]`);
|
|
2946
|
+
process.exit(1);
|
|
2947
|
+
}
|
|
2948
|
+
const stdin = await maybeReadStdin(flags);
|
|
2949
|
+
return printResult(await checkpointEditFlow(runId, n, { text: flagString(flags, "text"), file: flagString(flags, "file"), stdin }, { json }, defaultDeps));
|
|
2950
|
+
}
|
|
2951
|
+
if (action === "approve") {
|
|
2952
|
+
return printResult(await checkpointApproveFlow(runId, {
|
|
2953
|
+
wait: flags["wait"] === true,
|
|
2954
|
+
json,
|
|
2955
|
+
onProgressLine: (line) => console.log(line),
|
|
2956
|
+
...(flags["reject"] !== undefined
|
|
2957
|
+
? { reject: flagString(flags, "reject") ?? "" }
|
|
2958
|
+
: {}),
|
|
2959
|
+
}, defaultDeps));
|
|
2960
|
+
}
|
|
2961
|
+
if (action === "retry") {
|
|
2962
|
+
return printResult(await checkpointRetryFlow(runId, {
|
|
2963
|
+
wait: flags["wait"] === true,
|
|
2964
|
+
json,
|
|
2965
|
+
...(flags["note"] !== undefined
|
|
2966
|
+
? { note: flagString(flags, "note") }
|
|
2967
|
+
: {}),
|
|
2968
|
+
onProgressLine: (line) => console.log(line),
|
|
2969
|
+
}, defaultDeps));
|
|
2970
|
+
}
|
|
2971
|
+
if (action === "cancel") {
|
|
2972
|
+
return printResult(await checkpointCancelFlow(runId, { reason: flagString(flags, "reason"), json }, defaultDeps));
|
|
2973
|
+
}
|
|
2974
|
+
console.error(`Error: unknown checkpoint action "${action}" (expected show, edit, approve, retry, or cancel).`);
|
|
2975
|
+
process.exit(1);
|
|
2976
|
+
}
|
|
2977
|
+
if (sub === "repair") {
|
|
2978
|
+
const runId = rest[0];
|
|
2979
|
+
if (!runId) {
|
|
2980
|
+
console.error("Error: workflow repair requires <runId>.");
|
|
2981
|
+
console.log("Usage: exodus workflow repair <runId> retry|skip|kill [--wait]");
|
|
2982
|
+
process.exit(1);
|
|
2983
|
+
}
|
|
2984
|
+
const action = rest[1];
|
|
2985
|
+
if (action !== "retry" && action !== "skip" && action !== "kill") {
|
|
2986
|
+
console.error(`Error: workflow repair needs an action (retry, skip, or kill)${action ? ` — got "${action}"` : ""}.`);
|
|
2987
|
+
console.log("Usage: exodus workflow repair <runId> retry|skip|kill [--wait]");
|
|
2988
|
+
process.exit(1);
|
|
2989
|
+
}
|
|
2990
|
+
return printResult(await repairFlow(runId, action, { wait: flags["wait"] === true, json, onProgressLine: (line) => console.log(line) }, defaultDeps));
|
|
2991
|
+
}
|
|
2992
|
+
if (sub === "answer") {
|
|
2993
|
+
const runId = rest[0];
|
|
2994
|
+
if (!runId) {
|
|
2995
|
+
console.error("Error: workflow answer requires <runId>.");
|
|
2996
|
+
console.log("Usage: exodus workflow answer <runId> --slot key=value [--slot key=value ...]");
|
|
2997
|
+
process.exit(1);
|
|
2998
|
+
}
|
|
2999
|
+
let values;
|
|
3000
|
+
try {
|
|
3001
|
+
values = parseSlotFlags(process.argv.slice(3));
|
|
3002
|
+
}
|
|
3003
|
+
catch (e) {
|
|
3004
|
+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
3005
|
+
process.exit(1);
|
|
3006
|
+
}
|
|
3007
|
+
return printResult(await answerFlow(runId, values, { json }, defaultDeps));
|
|
3008
|
+
}
|
|
3009
|
+
if (sub === "triggers") {
|
|
3010
|
+
const workflowRef = rest[0];
|
|
3011
|
+
if (!workflowRef) {
|
|
3012
|
+
console.error("Error: workflow triggers requires <workflowId|name>.");
|
|
3013
|
+
console.log("Usage: exodus workflow triggers <workflowId|name> [enable|disable <n> | fire [<n>]] [--json]");
|
|
3014
|
+
process.exit(1);
|
|
3015
|
+
}
|
|
3016
|
+
const action = rest[1];
|
|
3017
|
+
if (!action)
|
|
3018
|
+
return printResult(await triggersListFlow(workflowRef, { json }, defaultDeps));
|
|
3019
|
+
if (action === "enable" || action === "disable") {
|
|
3020
|
+
const n = parseTriggerIndex(rest[2]);
|
|
3021
|
+
if (n === undefined) {
|
|
3022
|
+
console.error(`Error: workflow triggers ${action} requires a 1-based trigger number <n>.`);
|
|
3023
|
+
console.log(`Usage: exodus workflow triggers <workflowId|name> ${action} <n>`);
|
|
3024
|
+
process.exit(1);
|
|
3025
|
+
}
|
|
3026
|
+
return printResult(await triggersSetEnabledFlow(workflowRef, n, action === "enable", { json }, defaultDeps));
|
|
3027
|
+
}
|
|
3028
|
+
if (action === "fire") {
|
|
3029
|
+
let n;
|
|
3030
|
+
if (rest[2] !== undefined) {
|
|
3031
|
+
n = parseTriggerIndex(rest[2]);
|
|
3032
|
+
if (n === undefined) {
|
|
3033
|
+
console.error(`Error: trigger number must be a positive integer (got "${rest[2]}").`);
|
|
3034
|
+
process.exit(1);
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
let fireOverrides;
|
|
3038
|
+
try {
|
|
3039
|
+
fireOverrides = parseRigOverridesFlag(process.argv.slice(3), defaultDeps.readFile);
|
|
3040
|
+
}
|
|
3041
|
+
catch (e) {
|
|
3042
|
+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
3043
|
+
process.exit(1);
|
|
3044
|
+
}
|
|
3045
|
+
return printResult(await triggersFireFlow(workflowRef, {
|
|
3046
|
+
n,
|
|
3047
|
+
text: flagString(flags, "text"),
|
|
3048
|
+
imageRigOverrides: fireOverrides,
|
|
3049
|
+
wait: flags["wait"] === true,
|
|
3050
|
+
json,
|
|
3051
|
+
onProgressLine: (line) => console.log(line),
|
|
3052
|
+
}, defaultDeps));
|
|
3053
|
+
}
|
|
3054
|
+
console.error(`Error: unknown triggers action "${action}" (expected enable, disable, or fire).`);
|
|
3055
|
+
console.log("Usage: exodus workflow triggers <workflowId|name> [enable|disable <n> | fire [<n>]] [--json]");
|
|
3056
|
+
process.exit(1);
|
|
3057
|
+
}
|
|
760
3058
|
console.error(`Unknown subcommand: "${sub}"\n`);
|
|
761
3059
|
console.log(helpText);
|
|
762
3060
|
process.exit(1);
|
|
763
3061
|
}
|
|
3062
|
+
function parseTriggerIndex(raw) {
|
|
3063
|
+
if (raw === undefined)
|
|
3064
|
+
return undefined;
|
|
3065
|
+
if (!/^\d+$/.test(raw))
|
|
3066
|
+
return undefined;
|
|
3067
|
+
const n = Number(raw);
|
|
3068
|
+
return n >= 1 ? n : undefined;
|
|
3069
|
+
}
|