@dreamlake/dreamlake-cli 0.2.0 → 0.9.2
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/bin/dreamlake.js +38 -26
- package/package.json +19 -27
- package/README.md +0 -171
- package/dist/cli/auth/commands.js +0 -240
- package/dist/cli/auth/constants.js +0 -16
- package/dist/cli/auth/credentials.js +0 -157
- package/dist/cli/auth/device-flow.js +0 -134
- package/dist/cli/auth/device-secret.js +0 -34
- package/dist/cli/client.js +0 -99
- package/dist/cli/config.js +0 -81
- package/dist/cli/create/index.js +0 -204
- package/dist/cli/delete/index.js +0 -228
- package/dist/cli/download/index.js +0 -128
- package/dist/cli/glob.js +0 -45
- package/dist/cli/graphql-helpers.js +0 -42
- package/dist/cli/graphql.js +0 -47
- package/dist/cli/helpers.js +0 -106
- package/dist/cli/index.js +0 -95
- package/dist/cli/list/index.js +0 -254
- package/dist/cli/org/index.js +0 -348
- package/dist/cli/pipeline/index.js +0 -606
- package/dist/cli/progress.js +0 -65
- package/dist/cli/prompt.js +0 -17
- package/dist/cli/resources.js +0 -134
- package/dist/cli/target.js +0 -85
- package/dist/cli/team/index.js +0 -411
- package/dist/cli/update/index.js +0 -256
- package/dist/cli/upload/index.js +0 -263
- package/dist/cli/upload/kinds.js +0 -85
- package/dist/cli/upload/multipart.js +0 -211
- package/dist/cli/workflow/index.js +0 -627
|
@@ -1,627 +0,0 @@
|
|
|
1
|
-
// `dreamlake workflow ...` — workflow management + run-trace push.
|
|
2
|
-
//
|
|
3
|
-
// Covers the workflow API endpoints:
|
|
4
|
-
// workflow list / create / show / update / delete
|
|
5
|
-
// workflow push-run / watch-run (agent-side run-trace snapshots)
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
7
|
-
import { resolve } from "node:path";
|
|
8
|
-
import { HttpError, requestJson } from "../client.js";
|
|
9
|
-
import { resolveRemote, resolveToken, resolveNamespace } from "../config.js";
|
|
10
|
-
import { emitJson, fail, ok, renderTable, splitCsv, warn } from "../helpers.js";
|
|
11
|
-
import { confirm } from "../prompt.js";
|
|
12
|
-
// ─── shared context resolution ────────────────────────────────────────────────
|
|
13
|
-
async function ctx(nsFlag) {
|
|
14
|
-
const token = resolveToken();
|
|
15
|
-
if (!token) {
|
|
16
|
-
fail("not authenticated — run 'dreamlake login' first");
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
const remote = resolveRemote();
|
|
20
|
-
const ns = await resolveNamespace(nsFlag, { token, remote });
|
|
21
|
-
if (!ns) {
|
|
22
|
-
fail("could not resolve namespace — run 'dreamlake login' or pass --namespace");
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
return { token, remote, ns };
|
|
26
|
-
}
|
|
27
|
-
function readFile(filePath) {
|
|
28
|
-
try {
|
|
29
|
-
return readFileSync(resolve(filePath), "utf8");
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
throw new Error(`cannot read file: ${filePath}`);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
// ─── run-file reduction (local wf_*.json → server trace contract) ─────────────
|
|
36
|
-
//
|
|
37
|
-
// The Claude Code Workflow tool writes wf_<runId>.json snapshots under the
|
|
38
|
-
// session's workflows/ dir. `workflowProgress` interleaves `workflow_phase`
|
|
39
|
-
// and `workflow_agent` entries; run totals live at the top level. The file
|
|
40
|
-
// format drifts across CLI versions, so every field is optional — reduce
|
|
41
|
-
// what's there, drop the rest.
|
|
42
|
-
const AGENT_FIELDS = [
|
|
43
|
-
"index",
|
|
44
|
-
"label",
|
|
45
|
-
"phaseIndex",
|
|
46
|
-
"phaseTitle",
|
|
47
|
-
"agentId",
|
|
48
|
-
"model",
|
|
49
|
-
"state",
|
|
50
|
-
"queuedAt",
|
|
51
|
-
"startedAt",
|
|
52
|
-
"lastProgressAt",
|
|
53
|
-
"durationMs",
|
|
54
|
-
"attempt",
|
|
55
|
-
"tokens",
|
|
56
|
-
"toolCalls",
|
|
57
|
-
"lastToolName",
|
|
58
|
-
"lastToolSummary",
|
|
59
|
-
"promptPreview",
|
|
60
|
-
"resultPreview",
|
|
61
|
-
];
|
|
62
|
-
// ─── body-budget guardrails (bounded push size) ──────────────────────────────
|
|
63
|
-
//
|
|
64
|
-
// Every push-run / watch-run PUT must stay under the server's 5MB bodyLimit.
|
|
65
|
-
// An oversized body 413s; for the *terminal* snapshot that means the server-side
|
|
66
|
-
// run is never flipped out of 'running', so the web detail page polls it every
|
|
67
|
-
// few seconds forever. We hold the serialized body under a 4MB budget (headroom
|
|
68
|
-
// below the 5MB limit) and degrade deterministically — least-important data
|
|
69
|
-
// first — rather than ever emit a body we know will be rejected:
|
|
70
|
-
//
|
|
71
|
-
// 1. always: cap each agent's free-text previews to AGENT_PREVIEW_CAP chars.
|
|
72
|
-
// 2. always: keep only the newest log lines that fit LOGS_BUDGET_BYTES.
|
|
73
|
-
// 3. if the whole body is still over budget: drop logs, then replace `result`
|
|
74
|
-
// with a size marker. status / trace skeleton (phases+agents) / totals
|
|
75
|
-
// always survive — they are what the detail page needs to render.
|
|
76
|
-
/** Serialized-body ceiling. Below the server's 5MB bodyLimit, with headroom. */
|
|
77
|
-
const BODY_BUDGET_BYTES = 4 * 1024 * 1024;
|
|
78
|
-
/** Per-field cap for free-text agent previews. */
|
|
79
|
-
const AGENT_PREVIEW_CAP = 2000;
|
|
80
|
-
/** Combined byte budget for retained log lines (the newest are kept). */
|
|
81
|
-
const LOGS_BUDGET_BYTES = 512 * 1024;
|
|
82
|
-
/** Agent fields that are free-text and get length-capped. */
|
|
83
|
-
const CAPPED_AGENT_FIELDS = new Set([
|
|
84
|
-
"promptPreview",
|
|
85
|
-
"resultPreview",
|
|
86
|
-
"lastToolSummary",
|
|
87
|
-
]);
|
|
88
|
-
function byteLength(v) {
|
|
89
|
-
return Buffer.byteLength(JSON.stringify(v), "utf8");
|
|
90
|
-
}
|
|
91
|
-
/** Cap a free-text field to `max` chars, appending an ellipsis when trimmed. */
|
|
92
|
-
function capText(v, max) {
|
|
93
|
-
return typeof v === "string" && v.length > max ? v.slice(0, max) + "…" : v;
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Keep the LAST log lines that fit within `budget` bytes; when any are dropped
|
|
97
|
-
* prepend a marker so the reader knows the head was elided. Always keeps at
|
|
98
|
-
* least the most recent line (even if it alone exceeds the budget) so the tail
|
|
99
|
-
* of the run is never lost. Returns the input untouched when nothing is dropped.
|
|
100
|
-
*/
|
|
101
|
-
function capLogs(logs, budget) {
|
|
102
|
-
if (logs.length === 0)
|
|
103
|
-
return logs;
|
|
104
|
-
const kept = [];
|
|
105
|
-
let used = 0;
|
|
106
|
-
for (let i = logs.length - 1; i >= 0; i--) {
|
|
107
|
-
const line = logs[i];
|
|
108
|
-
const cost = Buffer.byteLength(line, "utf8") + 1; // + newline separator
|
|
109
|
-
if (kept.length > 0 && used + cost > budget)
|
|
110
|
-
break;
|
|
111
|
-
kept.push(line);
|
|
112
|
-
used += cost;
|
|
113
|
-
}
|
|
114
|
-
if (kept.length === logs.length)
|
|
115
|
-
return logs; // nothing dropped — keep as-is
|
|
116
|
-
kept.reverse();
|
|
117
|
-
kept.unshift(`[truncated ${logs.length - kept.length} earlier log lines]`);
|
|
118
|
-
return kept;
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Final guardrail: if the serialized body still exceeds BODY_BUDGET_BYTES after
|
|
122
|
-
* the per-field/log caps (e.g. a huge `result` or thousands of agents), degrade
|
|
123
|
-
* further — drop logs entirely, then replace `result` with a size marker. The
|
|
124
|
-
* status, trace skeleton (phases + agents) and run totals are never touched.
|
|
125
|
-
*/
|
|
126
|
-
function enforceBodyBudget(body, trace) {
|
|
127
|
-
if (byteLength(body) <= BODY_BUDGET_BYTES)
|
|
128
|
-
return;
|
|
129
|
-
// 1. Drop logs — the phase/agent skeleton is worth far more than log tail.
|
|
130
|
-
if (trace.logs.length > 0) {
|
|
131
|
-
trace.logs = [];
|
|
132
|
-
if (byteLength(body) <= BODY_BUDGET_BYTES)
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
// 2. Replace the result payload with a marker of its original size.
|
|
136
|
-
if (body.result !== undefined) {
|
|
137
|
-
body.result = { truncated: true, originalBytes: byteLength(body.result) };
|
|
138
|
-
}
|
|
139
|
-
// If still over budget here, the agent/phase skeleton itself is oversized;
|
|
140
|
-
// that is the documented floor — those fields must survive intact even if
|
|
141
|
-
// the push risks a 413.
|
|
142
|
-
}
|
|
143
|
-
function asRecord(v) {
|
|
144
|
-
return v && typeof v === "object" && !Array.isArray(v)
|
|
145
|
-
? v
|
|
146
|
-
: null;
|
|
147
|
-
}
|
|
148
|
-
/** Epoch-millis (number) or ISO string → ISO string; anything else → undefined. */
|
|
149
|
-
function isoTime(v) {
|
|
150
|
-
if (typeof v === "number" && Number.isFinite(v))
|
|
151
|
-
return new Date(v).toISOString();
|
|
152
|
-
if (typeof v === "string" && v)
|
|
153
|
-
return v;
|
|
154
|
-
return undefined;
|
|
155
|
-
}
|
|
156
|
-
export function reduceRunFile(raw) {
|
|
157
|
-
const file = asRecord(raw) ?? {};
|
|
158
|
-
const progress = Array.isArray(file.workflowProgress) ? file.workflowProgress : [];
|
|
159
|
-
const phases = [];
|
|
160
|
-
const agents = [];
|
|
161
|
-
for (const entry of progress) {
|
|
162
|
-
const e = asRecord(entry);
|
|
163
|
-
if (!e)
|
|
164
|
-
continue;
|
|
165
|
-
if (e.type === "workflow_phase") {
|
|
166
|
-
const phase = {};
|
|
167
|
-
if (e.index !== undefined)
|
|
168
|
-
phase.index = e.index;
|
|
169
|
-
if (e.title !== undefined)
|
|
170
|
-
phase.title = e.title;
|
|
171
|
-
phases.push(phase);
|
|
172
|
-
}
|
|
173
|
-
else if (e.type === "workflow_agent") {
|
|
174
|
-
const agent = {};
|
|
175
|
-
for (const f of AGENT_FIELDS) {
|
|
176
|
-
if (e[f] === undefined)
|
|
177
|
-
continue;
|
|
178
|
-
// Free-text previews are length-capped; everything else copied verbatim.
|
|
179
|
-
agent[f] = CAPPED_AGENT_FIELDS.has(f)
|
|
180
|
-
? capText(e[f], AGENT_PREVIEW_CAP)
|
|
181
|
-
: e[f];
|
|
182
|
-
}
|
|
183
|
-
agents.push(agent);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
// Before the first phase starts running, fall back to the declared meta
|
|
187
|
-
// phases so the graph still renders the skeleton.
|
|
188
|
-
if (phases.length === 0 && Array.isArray(file.phases)) {
|
|
189
|
-
file.phases.forEach((p, i) => {
|
|
190
|
-
const rec = asRecord(p);
|
|
191
|
-
if (rec?.title !== undefined)
|
|
192
|
-
phases.push({ index: i + 1, title: rec.title });
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
const trace = {
|
|
196
|
-
phases,
|
|
197
|
-
agents,
|
|
198
|
-
logs: capLogs(Array.isArray(file.logs)
|
|
199
|
-
? file.logs.filter((l) => typeof l === "string")
|
|
200
|
-
: [], LOGS_BUDGET_BYTES),
|
|
201
|
-
};
|
|
202
|
-
if (typeof file.totalToolCalls === "number")
|
|
203
|
-
trace.totalToolCalls = file.totalToolCalls;
|
|
204
|
-
if (typeof file.defaultModel === "string")
|
|
205
|
-
trace.defaultModel = file.defaultModel;
|
|
206
|
-
const body = {
|
|
207
|
-
status: typeof file.status === "string" && file.status ? file.status : "running",
|
|
208
|
-
trace,
|
|
209
|
-
};
|
|
210
|
-
const startTime = isoTime(file.startTime);
|
|
211
|
-
if (startTime)
|
|
212
|
-
body.startTime = startTime;
|
|
213
|
-
if (typeof file.durationMs === "number")
|
|
214
|
-
body.durationMs = file.durationMs;
|
|
215
|
-
if (typeof file.agentCount === "number")
|
|
216
|
-
body.agentCount = file.agentCount;
|
|
217
|
-
if (typeof file.totalTokens === "number")
|
|
218
|
-
body.totalTokens = file.totalTokens;
|
|
219
|
-
if (typeof file.error === "string" && file.error)
|
|
220
|
-
body.error = file.error;
|
|
221
|
-
if (file.result !== undefined)
|
|
222
|
-
body.result = file.result;
|
|
223
|
-
// Guarantee the body fits under the server bodyLimit before it is ever sent.
|
|
224
|
-
enforceBodyBudget(body, trace);
|
|
225
|
-
return {
|
|
226
|
-
runId: typeof file.runId === "string" && file.runId ? file.runId : undefined,
|
|
227
|
-
body,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
function readRunFile(filePath) {
|
|
231
|
-
const text = readFileSync(resolve(filePath), "utf8");
|
|
232
|
-
return reduceRunFile(JSON.parse(text));
|
|
233
|
-
}
|
|
234
|
-
// ─── workflow list ────────────────────────────────────────────────────────────
|
|
235
|
-
export async function runWorkflowList(opts) {
|
|
236
|
-
const c = await ctx(opts.namespace);
|
|
237
|
-
if (!c)
|
|
238
|
-
return 1;
|
|
239
|
-
try {
|
|
240
|
-
const res = await requestJson(c.remote, `/namespaces/${c.ns}/workflows`, { token: c.token });
|
|
241
|
-
if (opts.json) {
|
|
242
|
-
emitJson(res.workflows);
|
|
243
|
-
return 0;
|
|
244
|
-
}
|
|
245
|
-
if (res.workflows.length === 0) {
|
|
246
|
-
process.stdout.write("No workflows found.\n");
|
|
247
|
-
return 0;
|
|
248
|
-
}
|
|
249
|
-
const rows = res.workflows.map((w) => ({
|
|
250
|
-
name: w.name,
|
|
251
|
-
hash: w.currentVersionHash ?? "—",
|
|
252
|
-
runs: String(w.runCount ?? 0),
|
|
253
|
-
lastRun: w.latestRun?.status ?? "—",
|
|
254
|
-
updatedAt: w.updatedAt.slice(0, 10),
|
|
255
|
-
}));
|
|
256
|
-
process.stdout.write(renderTable(rows, ["name", "hash", "runs", "lastRun", "updatedAt"]));
|
|
257
|
-
process.stdout.write(`\n ${res.workflows.length} workflow(s)\n`);
|
|
258
|
-
return 0;
|
|
259
|
-
}
|
|
260
|
-
catch (err) {
|
|
261
|
-
fail(err.message);
|
|
262
|
-
return 1;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
// ─── workflow create ──────────────────────────────────────────────────────────
|
|
266
|
-
export async function runWorkflowCreate(name, opts) {
|
|
267
|
-
const c = await ctx(opts.namespace);
|
|
268
|
-
if (!c)
|
|
269
|
-
return 1;
|
|
270
|
-
try {
|
|
271
|
-
const body = { name };
|
|
272
|
-
if (opts.description)
|
|
273
|
-
body.description = opts.description;
|
|
274
|
-
if (opts.tags)
|
|
275
|
-
body.tags = splitCsv(opts.tags);
|
|
276
|
-
if (opts.file) {
|
|
277
|
-
body.script = readFile(opts.file);
|
|
278
|
-
if (opts.message)
|
|
279
|
-
body.versionMessage = opts.message;
|
|
280
|
-
}
|
|
281
|
-
const workflow = await requestJson(c.remote, `/namespaces/${c.ns}/workflows`, { method: "POST", token: c.token, json: body });
|
|
282
|
-
if (opts.json) {
|
|
283
|
-
emitJson(workflow);
|
|
284
|
-
return 0;
|
|
285
|
-
}
|
|
286
|
-
ok(`Created workflow: ${workflow.name}`);
|
|
287
|
-
if (workflow.currentVersionHash) {
|
|
288
|
-
process.stdout.write(` version: ${workflow.currentVersionHash}\n`);
|
|
289
|
-
process.stdout.write(` phases: ${workflow.meta?.phases?.length ?? 0}\n`);
|
|
290
|
-
}
|
|
291
|
-
return 0;
|
|
292
|
-
}
|
|
293
|
-
catch (err) {
|
|
294
|
-
if (err instanceof HttpError && err.status === 409) {
|
|
295
|
-
fail(`workflow '${name}' already exists in namespace '${c.ns}'`);
|
|
296
|
-
return 1;
|
|
297
|
-
}
|
|
298
|
-
fail(err.message);
|
|
299
|
-
return 1;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
// ─── workflow show ────────────────────────────────────────────────────────────
|
|
303
|
-
export async function runWorkflowShow(name, opts) {
|
|
304
|
-
const c = await ctx(opts.namespace);
|
|
305
|
-
if (!c)
|
|
306
|
-
return 1;
|
|
307
|
-
try {
|
|
308
|
-
const workflow = await requestJson(c.remote, `/namespaces/${c.ns}/workflows/${name}`, { token: c.token });
|
|
309
|
-
if (opts.json) {
|
|
310
|
-
emitJson(workflow);
|
|
311
|
-
return 0;
|
|
312
|
-
}
|
|
313
|
-
process.stdout.write(`Workflow: ${workflow.name}\n`);
|
|
314
|
-
process.stdout.write(` version: ${workflow.currentVersionHash ?? "—"}\n`);
|
|
315
|
-
process.stdout.write(` phases: ${workflow.meta?.phases?.length ?? "—"}\n`);
|
|
316
|
-
if (workflow.description)
|
|
317
|
-
process.stdout.write(` description: ${workflow.description}\n`);
|
|
318
|
-
if (workflow.tags.length)
|
|
319
|
-
process.stdout.write(` tags: ${workflow.tags.join(", ")}\n`);
|
|
320
|
-
process.stdout.write(` created: ${workflow.createdAt.slice(0, 10)}\n`);
|
|
321
|
-
process.stdout.write(` updated: ${workflow.updatedAt.slice(0, 10)}\n`);
|
|
322
|
-
return 0;
|
|
323
|
-
}
|
|
324
|
-
catch (err) {
|
|
325
|
-
if (err instanceof HttpError && err.status === 404) {
|
|
326
|
-
fail(`workflow '${name}' not found`);
|
|
327
|
-
return 1;
|
|
328
|
-
}
|
|
329
|
-
fail(err.message);
|
|
330
|
-
return 1;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
// ─── workflow update ──────────────────────────────────────────────────────────
|
|
334
|
-
export async function runWorkflowUpdate(name, opts) {
|
|
335
|
-
const c = await ctx(opts.namespace);
|
|
336
|
-
if (!c)
|
|
337
|
-
return 1;
|
|
338
|
-
const body = {};
|
|
339
|
-
if (opts.description !== undefined)
|
|
340
|
-
body.description = opts.description;
|
|
341
|
-
if (opts.tags !== undefined)
|
|
342
|
-
body.tags = splitCsv(opts.tags);
|
|
343
|
-
if (opts.file) {
|
|
344
|
-
try {
|
|
345
|
-
body.script = readFile(opts.file);
|
|
346
|
-
}
|
|
347
|
-
catch (err) {
|
|
348
|
-
fail(err.message);
|
|
349
|
-
return 1;
|
|
350
|
-
}
|
|
351
|
-
if (opts.message)
|
|
352
|
-
body.versionMessage = opts.message;
|
|
353
|
-
}
|
|
354
|
-
if (Object.keys(body).length === 0) {
|
|
355
|
-
fail("nothing to update — use --file, --description, or --tags");
|
|
356
|
-
return 1;
|
|
357
|
-
}
|
|
358
|
-
try {
|
|
359
|
-
const workflow = await requestJson(c.remote, `/namespaces/${c.ns}/workflows/${name}`, { method: "PATCH", token: c.token, json: body });
|
|
360
|
-
if (opts.json) {
|
|
361
|
-
emitJson(workflow);
|
|
362
|
-
return 0;
|
|
363
|
-
}
|
|
364
|
-
ok(`Updated workflow: ${workflow.name}`);
|
|
365
|
-
if (workflow.currentVersionHash) {
|
|
366
|
-
process.stdout.write(` version: ${workflow.currentVersionHash}\n`);
|
|
367
|
-
}
|
|
368
|
-
return 0;
|
|
369
|
-
}
|
|
370
|
-
catch (err) {
|
|
371
|
-
if (err instanceof HttpError && err.status === 404) {
|
|
372
|
-
fail(`workflow '${name}' not found`);
|
|
373
|
-
return 1;
|
|
374
|
-
}
|
|
375
|
-
fail(err.message);
|
|
376
|
-
return 1;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
// ─── workflow delete ──────────────────────────────────────────────────────────
|
|
380
|
-
export async function runWorkflowDelete(name, opts) {
|
|
381
|
-
const c = await ctx(opts.namespace);
|
|
382
|
-
if (!c)
|
|
383
|
-
return 1;
|
|
384
|
-
if (!opts.yes) {
|
|
385
|
-
const proceed = await confirm(`Delete workflow '${name}'?`, false);
|
|
386
|
-
if (!proceed) {
|
|
387
|
-
process.stdout.write("Cancelled.\n");
|
|
388
|
-
return 0;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
try {
|
|
392
|
-
await requestJson(c.remote, `/namespaces/${c.ns}/workflows/${name}`, { method: "DELETE", token: c.token });
|
|
393
|
-
ok(`Deleted workflow: ${name}`);
|
|
394
|
-
return 0;
|
|
395
|
-
}
|
|
396
|
-
catch (err) {
|
|
397
|
-
if (err instanceof HttpError && err.status === 404) {
|
|
398
|
-
fail(`workflow '${name}' not found`);
|
|
399
|
-
return 1;
|
|
400
|
-
}
|
|
401
|
-
fail(err.message);
|
|
402
|
-
return 1;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
// ─── workflow push-run ────────────────────────────────────────────────────────
|
|
406
|
-
export async function runWorkflowPushRun(name, filePath, opts) {
|
|
407
|
-
const c = await ctx(opts.namespace);
|
|
408
|
-
if (!c)
|
|
409
|
-
return 1;
|
|
410
|
-
let reduced;
|
|
411
|
-
try {
|
|
412
|
-
reduced = readRunFile(filePath);
|
|
413
|
-
}
|
|
414
|
-
catch (err) {
|
|
415
|
-
fail(`cannot read run file ${filePath}: ${err.message}`);
|
|
416
|
-
return 1;
|
|
417
|
-
}
|
|
418
|
-
if (!reduced.runId) {
|
|
419
|
-
fail(`run file ${filePath} has no runId`);
|
|
420
|
-
return 1;
|
|
421
|
-
}
|
|
422
|
-
try {
|
|
423
|
-
const run = await requestJson(c.remote, `/namespaces/${c.ns}/workflows/${name}/runs/${reduced.runId}`, { method: "PUT", token: c.token, json: reduced.body });
|
|
424
|
-
if (opts.json) {
|
|
425
|
-
emitJson(run);
|
|
426
|
-
return 0;
|
|
427
|
-
}
|
|
428
|
-
ok(`Pushed run ${reduced.runId} → ${run.status ?? reduced.body.status}`);
|
|
429
|
-
return 0;
|
|
430
|
-
}
|
|
431
|
-
catch (err) {
|
|
432
|
-
if (err instanceof HttpError && err.status === 404) {
|
|
433
|
-
fail(`workflow '${name}' not found`);
|
|
434
|
-
return 1;
|
|
435
|
-
}
|
|
436
|
-
fail(err.message);
|
|
437
|
-
return 1;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* Backoff schedule for the *terminal* push. A running snapshot that fails is
|
|
442
|
-
* harmless — the next tick retries it — but the terminal snapshot is the one
|
|
443
|
-
* that flips the server run to its final state, and there is no next tick. If
|
|
444
|
-
* it is dropped on a transient error the server run is stranded as 'running'
|
|
445
|
-
* and the detail page polls it forever, so it gets its own bounded retry.
|
|
446
|
-
*/
|
|
447
|
-
const TERMINAL_RETRY_DELAYS_MS = [2000, 4000, 8000, 16000, 32000];
|
|
448
|
-
/**
|
|
449
|
-
* Cold-start grace window. The watcher is often launched *before* the Workflow
|
|
450
|
-
* tool has written the first wf_*.json (slow orchestration). Until the first
|
|
451
|
-
* successful read we tolerate a missing/unreadable file for this long, rather
|
|
452
|
-
* than letting the mid-run 5-strike cap (~25s at --interval 5) kill the watcher
|
|
453
|
-
* and leave the run with zero snapshots.
|
|
454
|
-
*/
|
|
455
|
-
const STARTUP_GRACE_MS = 120_000;
|
|
456
|
-
/**
|
|
457
|
-
* Push the terminal snapshot, retrying transient failures with exponential
|
|
458
|
-
* backoff. Returns 0 once accepted, 1 on a 404 (workflow gone) or after the
|
|
459
|
-
* retry budget is exhausted.
|
|
460
|
-
*/
|
|
461
|
-
async function pushTerminalSnapshot(c, name, reduced, status, sleep, delays) {
|
|
462
|
-
for (let attempt = 0;; attempt++) {
|
|
463
|
-
try {
|
|
464
|
-
await requestJson(c.remote, `/namespaces/${c.ns}/workflows/${name}/runs/${reduced.runId}`, { method: "PUT", token: c.token, json: reduced.body });
|
|
465
|
-
ok(`pushed ${reduced.runId} → ${status}`);
|
|
466
|
-
ok(`run ${reduced.runId} finished: ${status}`);
|
|
467
|
-
return 0;
|
|
468
|
-
}
|
|
469
|
-
catch (err) {
|
|
470
|
-
if (err instanceof HttpError && err.status === 404) {
|
|
471
|
-
fail(`workflow '${name}' not found`);
|
|
472
|
-
return 1;
|
|
473
|
-
}
|
|
474
|
-
if (attempt >= delays.length) {
|
|
475
|
-
fail(`final push failed after ${delays.length + 1} attempts: ${err.message}`);
|
|
476
|
-
return 1;
|
|
477
|
-
}
|
|
478
|
-
const delay = delays[attempt];
|
|
479
|
-
warn(`final push failed (${err.message}) — retry ${attempt + 1}/${delays.length} in ${delay / 1000}s`);
|
|
480
|
-
await sleep(delay);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
export async function runWorkflowWatchRun(name, filePath, opts, deps = {}) {
|
|
485
|
-
const c = await ctx(opts.namespace);
|
|
486
|
-
if (!c)
|
|
487
|
-
return 1;
|
|
488
|
-
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
489
|
-
const terminalDelays = deps.terminalRetryDelaysMs ?? TERMINAL_RETRY_DELAYS_MS;
|
|
490
|
-
const startupGraceMs = deps.startupGraceMs ?? STARTUP_GRACE_MS;
|
|
491
|
-
const parsed = Number(opts.interval);
|
|
492
|
-
const intervalMs = (Number.isFinite(parsed) && parsed > 0 ? parsed : 5) * 1000;
|
|
493
|
-
// Cold-start budget: how many read attempts fit in the grace window.
|
|
494
|
-
const startupAttempts = Math.max(1, Math.ceil(startupGraceMs / intervalMs));
|
|
495
|
-
let hasReadOnce = false; // has the run file ever been read successfully?
|
|
496
|
-
let readFailures = 0; // consecutive failures AFTER the first success (mid-run)
|
|
497
|
-
let startupMisses = 0; // consecutive misses BEFORE the first success (cold start)
|
|
498
|
-
for (;;) {
|
|
499
|
-
let reduced = null;
|
|
500
|
-
try {
|
|
501
|
-
reduced = readRunFile(filePath);
|
|
502
|
-
hasReadOnce = true;
|
|
503
|
-
readFailures = 0;
|
|
504
|
-
}
|
|
505
|
-
catch (err) {
|
|
506
|
-
if (!hasReadOnce) {
|
|
507
|
-
// Cold start: the Workflow tool may not have written the run file yet.
|
|
508
|
-
// Wait out the grace window instead of treating a not-yet-created file
|
|
509
|
-
// as fatal — otherwise a slow orchestrator start strands the run with
|
|
510
|
-
// zero snapshots. Log once, quietly, so we don't spam every tick.
|
|
511
|
-
startupMisses += 1;
|
|
512
|
-
if (startupMisses === 1)
|
|
513
|
-
warn(`waiting for run file ${filePath} …`);
|
|
514
|
-
if (startupMisses >= startupAttempts) {
|
|
515
|
-
fail(`run file ${filePath} did not appear within ${Math.round(startupGraceMs / 1000)}s — giving up: ${err.message}`);
|
|
516
|
-
return 1;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
else {
|
|
520
|
-
// Mid-run: the file existed and now can't be read (corrupt/deleted mid-
|
|
521
|
-
// write). Retry a few times, but don't spin forever.
|
|
522
|
-
readFailures += 1;
|
|
523
|
-
if (readFailures >= 5) {
|
|
524
|
-
fail(`cannot read run file ${filePath}: ${err.message}`);
|
|
525
|
-
return 1;
|
|
526
|
-
}
|
|
527
|
-
warn(`run file not readable (${err.message}) — retrying`);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
if (reduced) {
|
|
531
|
-
if (!reduced.runId) {
|
|
532
|
-
fail(`run file ${filePath} has no runId`);
|
|
533
|
-
return 1;
|
|
534
|
-
}
|
|
535
|
-
const status = reduced.body.status;
|
|
536
|
-
if (status !== "running") {
|
|
537
|
-
// Terminal snapshot — retry with backoff so a transient failure can't
|
|
538
|
-
// strand the server run as 'running'.
|
|
539
|
-
return pushTerminalSnapshot(c, name, reduced, status, sleep, terminalDelays);
|
|
540
|
-
}
|
|
541
|
-
try {
|
|
542
|
-
await requestJson(c.remote, `/namespaces/${c.ns}/workflows/${name}/runs/${reduced.runId}`, { method: "PUT", token: c.token, json: reduced.body });
|
|
543
|
-
ok(`pushed ${reduced.runId} → ${status}`);
|
|
544
|
-
}
|
|
545
|
-
catch (err) {
|
|
546
|
-
if (err instanceof HttpError && err.status === 404) {
|
|
547
|
-
fail(`workflow '${name}' not found`);
|
|
548
|
-
return 1;
|
|
549
|
-
}
|
|
550
|
-
// A running snapshot is disposable — the next tick will retry it.
|
|
551
|
-
warn(`push failed (${err.message}) — retrying`);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
await sleep(intervalMs);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
// ─── registration ─────────────────────────────────────────────────────────────
|
|
558
|
-
export function registerWorkflowCommand(program) {
|
|
559
|
-
const workflow = program
|
|
560
|
-
.command("workflow")
|
|
561
|
-
.description("manage workflows and push run traces");
|
|
562
|
-
// ── workflow list ──
|
|
563
|
-
workflow
|
|
564
|
-
.command("list")
|
|
565
|
-
.description("list workflows in a namespace")
|
|
566
|
-
.option("--namespace <slug>", "namespace slug (default: active login)")
|
|
567
|
-
.option("--json", "emit JSON")
|
|
568
|
-
.action(async (opts) => process.exit(await runWorkflowList(opts)));
|
|
569
|
-
// ── workflow create ──
|
|
570
|
-
workflow
|
|
571
|
-
.command("create")
|
|
572
|
-
.description("create a workflow (optionally with an initial version)")
|
|
573
|
-
.argument("<name>", "workflow name (unique within namespace)")
|
|
574
|
-
.option("--namespace <slug>", "namespace slug (default: active login)")
|
|
575
|
-
.option("--file <path>", "workflow JS script — meta is parsed and an initial version created")
|
|
576
|
-
.option("--description <text>", "description")
|
|
577
|
-
.option("--tags <csv>", "comma-separated tags")
|
|
578
|
-
.option("--message <text>", "version message (used with --file)")
|
|
579
|
-
.option("--json", "emit JSON")
|
|
580
|
-
.action(async (name, opts) => process.exit(await runWorkflowCreate(name, opts)));
|
|
581
|
-
// ── workflow show ──
|
|
582
|
-
workflow
|
|
583
|
-
.command("show")
|
|
584
|
-
.description("show workflow detail with current version meta")
|
|
585
|
-
.argument("<name>", "workflow name")
|
|
586
|
-
.option("--namespace <slug>", "namespace slug (default: active login)")
|
|
587
|
-
.option("--json", "emit JSON")
|
|
588
|
-
.action(async (name, opts) => process.exit(await runWorkflowShow(name, opts)));
|
|
589
|
-
// ── workflow update ──
|
|
590
|
-
workflow
|
|
591
|
-
.command("update")
|
|
592
|
-
.description("update metadata or upload a new version")
|
|
593
|
-
.argument("<name>", "workflow name")
|
|
594
|
-
.option("--namespace <slug>", "namespace slug (default: active login)")
|
|
595
|
-
.option("--file <path>", "new workflow JS script — creates a new version")
|
|
596
|
-
.option("--description <text>", "new description")
|
|
597
|
-
.option("--tags <csv>", "new tags (comma-separated, replaces existing)")
|
|
598
|
-
.option("--message <text>", "version message (used with --file)")
|
|
599
|
-
.option("--json", "emit JSON")
|
|
600
|
-
.action(async (name, opts) => process.exit(await runWorkflowUpdate(name, opts)));
|
|
601
|
-
// ── workflow delete ──
|
|
602
|
-
workflow
|
|
603
|
-
.command("delete")
|
|
604
|
-
.description("soft-delete a workflow (preserves version history)")
|
|
605
|
-
.argument("<name>", "workflow name")
|
|
606
|
-
.option("--namespace <slug>", "namespace slug (default: active login)")
|
|
607
|
-
.option("--yes", "skip confirmation prompt")
|
|
608
|
-
.action(async (name, opts) => process.exit(await runWorkflowDelete(name, opts)));
|
|
609
|
-
// ── workflow push-run ──
|
|
610
|
-
workflow
|
|
611
|
-
.command("push-run")
|
|
612
|
-
.description("push one run-trace snapshot from a local wf_*.json run file")
|
|
613
|
-
.argument("<name>", "workflow name")
|
|
614
|
-
.argument("<file>", "path to the local wf_*.json run file")
|
|
615
|
-
.option("--namespace <slug>", "namespace slug (default: active login)")
|
|
616
|
-
.option("--json", "emit JSON")
|
|
617
|
-
.action(async (name, file, opts) => process.exit(await runWorkflowPushRun(name, file, opts)));
|
|
618
|
-
// ── workflow watch-run ──
|
|
619
|
-
workflow
|
|
620
|
-
.command("watch-run")
|
|
621
|
-
.description("push snapshots until the run file leaves 'running', then a final push")
|
|
622
|
-
.argument("<name>", "workflow name")
|
|
623
|
-
.argument("<file>", "path to the local wf_*.json run file")
|
|
624
|
-
.option("--interval <seconds>", "seconds between pushes", "5")
|
|
625
|
-
.option("--namespace <slug>", "namespace slug (default: active login)")
|
|
626
|
-
.action(async (name, file, opts) => process.exit(await runWorkflowWatchRun(name, file, opts)));
|
|
627
|
-
}
|