@dreamlake/dreamlake-cli 0.1.0 → 0.2.0
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 +14 -34
- package/bin/dreamlake.js +1 -9
- package/dist/cli/index.js +2 -4
- package/dist/cli/pipeline/index.js +130 -5
- package/dist/cli/workflow/index.js +627 -0
- package/package.json +2 -23
- package/LICENSE +0 -21
package/README.md
CHANGED
|
@@ -4,36 +4,22 @@
|
|
|
4
4
|
manages episodes, bindrs, and datasets against a DreamLake server + BSS
|
|
5
5
|
(big-streaming-server).
|
|
6
6
|
|
|
7
|
-
It is a TypeScript port of the CLI shipped in
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
It is a TypeScript port of the CLI shipped in
|
|
8
|
+
[`dreamlake-py`](../dreamlake-workspace/dreamlake-py) (`dreamlake.cli`), built
|
|
9
|
+
on the [`lakeshore`](../lakeshore) conventions (Commander + native `fetch`,
|
|
10
|
+
pure ESM, token saved to a flat YAML file instead of the system keyring).
|
|
10
11
|
|
|
11
|
-
> The `dreamlake` binary shares its name with the Python `dreamlake`
|
|
12
|
-
> point — this CLI is the TypeScript replacement. When both are on a
|
|
13
|
-
> `PATH` order decides which one runs.
|
|
12
|
+
> The compiled `dreamlake` binary shares its name with the Python `dreamlake`
|
|
13
|
+
> entry point — this CLI is the TypeScript replacement. When both are on a
|
|
14
|
+
> machine, `PATH` order decides which one runs.
|
|
14
15
|
|
|
15
|
-
## Install
|
|
16
|
-
|
|
17
|
-
Requires Node.js >= 20.
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
npm install -g @dreamlake/dreamlake-cli # or: pnpm add -g @dreamlake/dreamlake-cli
|
|
21
|
-
|
|
22
|
-
dreamlake --version
|
|
23
|
-
dreamlake --help
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Or run it without installing:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
npx @dreamlake/dreamlake-cli --help
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Then authenticate and go:
|
|
16
|
+
## Install / run
|
|
33
17
|
|
|
34
18
|
```bash
|
|
35
|
-
|
|
36
|
-
|
|
19
|
+
pnpm install
|
|
20
|
+
pnpm cli --help # dev — runs the TS source through tsx
|
|
21
|
+
pnpm build # emit dist/
|
|
22
|
+
node bin/dreamlake.js … # prod — runs compiled dist/ (falls back to tsx in dev)
|
|
37
23
|
```
|
|
38
24
|
|
|
39
25
|
## Auth & environments
|
|
@@ -180,12 +166,6 @@ Episode-tracking stack.
|
|
|
180
166
|
## Development
|
|
181
167
|
|
|
182
168
|
```bash
|
|
183
|
-
pnpm
|
|
184
|
-
pnpm
|
|
185
|
-
pnpm test # tsc --noEmit + node --test (target/glob/kinds/credentials)
|
|
186
|
-
pnpm build # tsc → dist/
|
|
187
|
-
node bin/dreamlake.js … # runs compiled dist/ (falls back to tsx when dist/ is absent)
|
|
169
|
+
pnpm test # tsc --noEmit + node --test (target/glob/kinds/credentials)
|
|
170
|
+
pnpm build # tsc → dist/
|
|
188
171
|
```
|
|
189
|
-
|
|
190
|
-
Publishing: `npm version patch|minor` → `npm publish` (tests and build run
|
|
191
|
-
automatically via `prepublishOnly`/`prepack`) → `git push --follow-tags`.
|
package/bin/dreamlake.js
CHANGED
|
@@ -18,15 +18,7 @@ async function run() {
|
|
|
18
18
|
}
|
|
19
19
|
if (existsSync(srcEntry)) {
|
|
20
20
|
// Register tsx as a module loader, then import the TS source.
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
tsx = await import("tsx/esm/api");
|
|
24
|
-
} catch {
|
|
25
|
-
console.error(
|
|
26
|
-
"dreamlake: dist/ is missing and tsx is not installed — run `pnpm install && pnpm build` first",
|
|
27
|
-
);
|
|
28
|
-
process.exit(2);
|
|
29
|
-
}
|
|
21
|
+
const tsx = await import("tsx/esm/api");
|
|
30
22
|
tsx.register();
|
|
31
23
|
await import(pathToFileURL(srcEntry).href);
|
|
32
24
|
return;
|
package/dist/cli/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// `dreamlake` CLI entry. Wires Commander to subcommand modules. Mirrors
|
|
2
2
|
// the command surface of dreamlake-py's `dreamlake.cli` (the data-warehouse
|
|
3
3
|
// CLI), implemented in lakeshore's TypeScript conventions.
|
|
4
|
-
import { createRequire } from "node:module";
|
|
5
4
|
import { Command } from "commander";
|
|
6
5
|
import { authFilePath, BUILTIN_ENVS } from "./auth/credentials.js";
|
|
7
6
|
import { runLogin, runLogout, runProfile, runEnvList, runEnvUse, runEnvRemove, } from "./auth/commands.js";
|
|
@@ -14,14 +13,12 @@ import { registerDeleteCommand } from "./delete/index.js";
|
|
|
14
13
|
import { registerOrgCommand } from "./org/index.js";
|
|
15
14
|
import { registerTeamCommand } from "./team/index.js";
|
|
16
15
|
import { registerPipelineCommand } from "./pipeline/index.js";
|
|
17
|
-
|
|
18
|
-
const { version } = createRequire(import.meta.url)("../../package.json");
|
|
16
|
+
import { registerWorkflowCommand } from "./workflow/index.js";
|
|
19
17
|
async function main(argv) {
|
|
20
18
|
const program = new Command();
|
|
21
19
|
program
|
|
22
20
|
.name("dreamlake")
|
|
23
21
|
.description("DreamLake CLI — upload/download assets and manage episodes, bindrs, and datasets.")
|
|
24
|
-
.version(version)
|
|
25
22
|
.showHelpAfterError();
|
|
26
23
|
// ─── auth ────────────────────────────────────────────────────────
|
|
27
24
|
//
|
|
@@ -89,6 +86,7 @@ async function main(argv) {
|
|
|
89
86
|
registerOrgCommand(program);
|
|
90
87
|
registerTeamCommand(program);
|
|
91
88
|
registerPipelineCommand(program);
|
|
89
|
+
registerWorkflowCommand(program);
|
|
92
90
|
await program.parseAsync(argv);
|
|
93
91
|
}
|
|
94
92
|
main(process.argv).catch((err) => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// pipeline version list / version show
|
|
6
6
|
// pipeline node list / node show / node state
|
|
7
7
|
import { readFileSync } from "node:fs";
|
|
8
|
-
import { resolve } from "node:path";
|
|
8
|
+
import { resolve, basename } from "node:path";
|
|
9
9
|
import { HttpError, requestJson } from "../client.js";
|
|
10
10
|
import { resolveRemote, resolveToken, resolveNamespace } from "../config.js";
|
|
11
11
|
import { emitJson, fail, ok, renderTable, splitCsv } from "../helpers.js";
|
|
@@ -163,18 +163,20 @@ export async function runPipelineUpdate(name, opts) {
|
|
|
163
163
|
if (!c)
|
|
164
164
|
return 1;
|
|
165
165
|
const body = {};
|
|
166
|
+
if (opts.rename !== undefined)
|
|
167
|
+
body.name = opts.rename;
|
|
166
168
|
if (opts.description !== undefined)
|
|
167
169
|
body.description = opts.description;
|
|
168
170
|
if (opts.tags !== undefined)
|
|
169
171
|
body.tags = splitCsv(opts.tags);
|
|
170
|
-
const sourceCode = opts.source
|
|
172
|
+
const sourceCode = opts.source ? unescapeSource(opts.source) : (opts.file ? readFile(opts.file) : undefined);
|
|
171
173
|
if (sourceCode) {
|
|
172
174
|
body.sourceCode = sourceCode;
|
|
173
175
|
if (opts.message)
|
|
174
176
|
body.versionMessage = opts.message;
|
|
175
177
|
}
|
|
176
178
|
if (Object.keys(body).length === 0) {
|
|
177
|
-
fail("nothing to update — use --file, --description, or --tags");
|
|
179
|
+
fail("nothing to update — use --file, --source, --rename, --description, or --tags");
|
|
178
180
|
return 1;
|
|
179
181
|
}
|
|
180
182
|
try {
|
|
@@ -194,6 +196,10 @@ export async function runPipelineUpdate(name, opts) {
|
|
|
194
196
|
fail(`pipeline '${name}' not found`);
|
|
195
197
|
return 1;
|
|
196
198
|
}
|
|
199
|
+
if (err instanceof HttpError && err.status === 409) {
|
|
200
|
+
fail(`pipeline '${opts.rename}' already exists in this namespace`);
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
197
203
|
fail(err.message);
|
|
198
204
|
return 1;
|
|
199
205
|
}
|
|
@@ -348,7 +354,7 @@ export async function runNodeState(name, hash, nodeId, opts) {
|
|
|
348
354
|
body.artifacts = JSON.parse(opts.artifacts);
|
|
349
355
|
}
|
|
350
356
|
catch {
|
|
351
|
-
fail("--artifacts must be valid JSON, e.g. '{\"
|
|
357
|
+
fail("--artifacts must be valid JSON, e.g. '{\"iframe_url\":\"https://...\",\"segments_data\":[]}'");
|
|
352
358
|
return 1;
|
|
353
359
|
}
|
|
354
360
|
}
|
|
@@ -369,6 +375,100 @@ export async function runNodeState(name, hash, nodeId, opts) {
|
|
|
369
375
|
return 1;
|
|
370
376
|
}
|
|
371
377
|
}
|
|
378
|
+
// ─── pipeline workspace upload ────────────────────────────────────────────────
|
|
379
|
+
export async function runWorkspaceUpload(opts) {
|
|
380
|
+
const token = resolveToken();
|
|
381
|
+
if (!token) {
|
|
382
|
+
fail("not authenticated — run 'dreamlake login' first");
|
|
383
|
+
return 1;
|
|
384
|
+
}
|
|
385
|
+
const remote = resolveRemote();
|
|
386
|
+
const form = new FormData();
|
|
387
|
+
form.append("workspace_id", opts.workspaceId);
|
|
388
|
+
try {
|
|
389
|
+
const distBuf = readFileSync(resolve(opts.dist));
|
|
390
|
+
form.append("dist", new Blob([distBuf], { type: "application/gzip" }), basename(opts.dist));
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
fail(`cannot read dist file: ${opts.dist}`);
|
|
394
|
+
return 1;
|
|
395
|
+
}
|
|
396
|
+
if (opts.source) {
|
|
397
|
+
try {
|
|
398
|
+
const sourceBuf = readFileSync(resolve(opts.source));
|
|
399
|
+
form.append("source", new Blob([sourceBuf], { type: "application/gzip" }), basename(opts.source));
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
fail(`cannot read source file: ${opts.source}`);
|
|
403
|
+
return 1;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const res = await fetch(new URL("/pipelines/workspace/upload", remote).toString(), {
|
|
408
|
+
method: "POST",
|
|
409
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
410
|
+
body: form,
|
|
411
|
+
});
|
|
412
|
+
if (!res.ok) {
|
|
413
|
+
const body = await res.text().catch(() => "");
|
|
414
|
+
fail(`Upload failed (${res.status}): ${body}`);
|
|
415
|
+
return 1;
|
|
416
|
+
}
|
|
417
|
+
if (opts.json) {
|
|
418
|
+
emitJson(await res.json());
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
ok(`Workspace ${opts.workspaceId} updated`);
|
|
422
|
+
return 0;
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
fail(err.message);
|
|
426
|
+
return 1;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// ─── pipeline workspace host ──────────────────────────────────────────────────
|
|
430
|
+
export async function runWorkspaceHost(opts) {
|
|
431
|
+
const token = resolveToken();
|
|
432
|
+
if (!token) {
|
|
433
|
+
fail("not authenticated — run 'dreamlake login' first");
|
|
434
|
+
return 1;
|
|
435
|
+
}
|
|
436
|
+
const remote = resolveRemote();
|
|
437
|
+
let distBuf;
|
|
438
|
+
try {
|
|
439
|
+
distBuf = readFileSync(resolve(opts.dist));
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
fail(`cannot read dist file: ${opts.dist}`);
|
|
443
|
+
return 1;
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const res = await fetch(new URL("/pipelines/workspace/host", remote).toString(), {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/gzip" },
|
|
449
|
+
body: distBuf,
|
|
450
|
+
});
|
|
451
|
+
if (!res.ok) {
|
|
452
|
+
const body = await res.text().catch(() => "");
|
|
453
|
+
fail(`Host failed (${res.status}): ${body}`);
|
|
454
|
+
return 1;
|
|
455
|
+
}
|
|
456
|
+
const data = await res.json();
|
|
457
|
+
if (opts.json) {
|
|
458
|
+
emitJson(data);
|
|
459
|
+
return 0;
|
|
460
|
+
}
|
|
461
|
+
ok("Workspace hosted");
|
|
462
|
+
process.stdout.write(` workspace_id: ${data.workspace_id}\n`);
|
|
463
|
+
process.stdout.write(` page_url: ${data.page_url}\n`);
|
|
464
|
+
process.stdout.write(` dist_files_count: ${data.dist_files_count}\n`);
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
fail(err.message);
|
|
469
|
+
return 1;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
372
472
|
// ─── registration ─────────────────────────────────────────────────────────────
|
|
373
473
|
export function registerPipelineCommand(program) {
|
|
374
474
|
const pipeline = program
|
|
@@ -410,6 +510,7 @@ export function registerPipelineCommand(program) {
|
|
|
410
510
|
.description("update metadata or upload a new version")
|
|
411
511
|
.argument("<name>", "pipeline name")
|
|
412
512
|
.option("--namespace <slug>", "namespace slug (default: active login)")
|
|
513
|
+
.option("--rename <new-name>", "rename the pipeline (must be unique within namespace)")
|
|
413
514
|
.option("--source <code>", "new Python source code string — triggers parser and creates a new version")
|
|
414
515
|
.option("--file <path>", "new Python source file (alternative to --source)")
|
|
415
516
|
.option("--description <text>", "new description")
|
|
@@ -465,6 +566,24 @@ export function registerPipelineCommand(program) {
|
|
|
465
566
|
.option("--namespace <slug>", "namespace slug (default: active login)")
|
|
466
567
|
.option("--json", "emit JSON (always, this command outputs JSON)")
|
|
467
568
|
.action(async (name, hash, nodeId, opts) => process.exit(await runNodeShow(name, hash, nodeId, opts)));
|
|
569
|
+
// ── pipeline workspace ──
|
|
570
|
+
const workspace = pipeline
|
|
571
|
+
.command("workspace")
|
|
572
|
+
.description("manage agent workspaces");
|
|
573
|
+
workspace
|
|
574
|
+
.command("upload")
|
|
575
|
+
.description("upload dist.tar.gz (and optionally source.tar.gz) to an agent workspace")
|
|
576
|
+
.requiredOption("--workspace-id <id>", "agent workspace ID")
|
|
577
|
+
.requiredOption("--dist <path>", "path to dist.tar.gz (built React output)")
|
|
578
|
+
.option("--source <path>", "path to source.tar.gz (optional)")
|
|
579
|
+
.option("--json", "emit JSON response")
|
|
580
|
+
.action(async (opts) => process.exit(await runWorkspaceUpload(opts)));
|
|
581
|
+
workspace
|
|
582
|
+
.command("host")
|
|
583
|
+
.description("host a dist.tar.gz on the agent server and get back a page_url")
|
|
584
|
+
.requiredOption("--dist <path>", "path to dist.tar.gz (built React output)")
|
|
585
|
+
.option("--json", "emit JSON response")
|
|
586
|
+
.action(async (opts) => process.exit(await runWorkspaceHost(opts)));
|
|
468
587
|
node
|
|
469
588
|
.command("state")
|
|
470
589
|
.description("write back node execution state (for execution engines)")
|
|
@@ -473,7 +592,13 @@ export function registerPipelineCommand(program) {
|
|
|
473
592
|
.argument("<nodeId>", "node ID")
|
|
474
593
|
.requiredOption("--status <status>", "idle | queued | running | waiting | done | error | blocked")
|
|
475
594
|
.option("--error <message>", "error message (use with --status error)")
|
|
476
|
-
.option("--artifacts <json>",
|
|
595
|
+
.option("--artifacts <json>", [
|
|
596
|
+
"JSON object written to node artifacts. Supported keys:",
|
|
597
|
+
" iframe_url string — URL of the human review page (rendered in iframe)",
|
|
598
|
+
" source_url string — original data source URL",
|
|
599
|
+
" segments_data any — segment annotation data (any JSON value)",
|
|
600
|
+
"Example: '{\"iframe_url\":\"https://...\",\"segments_data\":[{\"start\":0,\"end\":10}]}'",
|
|
601
|
+
].join("\n"))
|
|
477
602
|
.option("--started-at <iso>", "ISO 8601 start time")
|
|
478
603
|
.option("--ended-at <iso>", "ISO 8601 end time")
|
|
479
604
|
.option("--namespace <slug>", "namespace slug (default: active login)")
|
|
@@ -0,0 +1,627 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreamlake/dreamlake-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "dreamlake — data-warehouse CLI. The `dreamlake` command uploads/downloads assets and manages episodes, bindrs, and datasets against a DreamLake server + BSS.",
|
|
5
5
|
"private": false,
|
|
6
|
-
"license": "MIT",
|
|
7
6
|
"type": "module",
|
|
7
|
+
"main": "dist/cli/index.js",
|
|
8
8
|
"bin": {
|
|
9
9
|
"dreamlake": "bin/dreamlake.js"
|
|
10
10
|
},
|
|
@@ -13,25 +13,6 @@
|
|
|
13
13
|
"dist",
|
|
14
14
|
"!dist/**/*.map"
|
|
15
15
|
],
|
|
16
|
-
"engines": {
|
|
17
|
-
"node": ">=20"
|
|
18
|
-
},
|
|
19
|
-
"repository": {
|
|
20
|
-
"type": "git",
|
|
21
|
-
"url": "git+https://github.com/dreamlake-ai/dreamlake-cli.git"
|
|
22
|
-
},
|
|
23
|
-
"homepage": "https://github.com/dreamlake-ai/dreamlake-cli#readme",
|
|
24
|
-
"bugs": {
|
|
25
|
-
"url": "https://github.com/dreamlake-ai/dreamlake-cli/issues"
|
|
26
|
-
},
|
|
27
|
-
"keywords": [
|
|
28
|
-
"dreamlake",
|
|
29
|
-
"cli",
|
|
30
|
-
"data-warehouse",
|
|
31
|
-
"dataset",
|
|
32
|
-
"robotics"
|
|
33
|
-
],
|
|
34
|
-
"author": "DreamLake AI",
|
|
35
16
|
"publishConfig": {
|
|
36
17
|
"access": "public"
|
|
37
18
|
},
|
|
@@ -39,8 +20,6 @@
|
|
|
39
20
|
"build": "tsc",
|
|
40
21
|
"cli": "tsx src/cli/index.ts",
|
|
41
22
|
"test": "tsc -p tsconfig.json --noEmit && node --import tsx --test src/cli/__tests__/*.test.ts",
|
|
42
|
-
"prepublishOnly": "npm test",
|
|
43
|
-
"prepack": "npm run build",
|
|
44
23
|
"docs:dev": "pnpm -C docs dev",
|
|
45
24
|
"docs:build": "pnpm -C docs build",
|
|
46
25
|
"docs:preview": "pnpm -C docs preview"
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 DreamLake AI
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|