@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 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 `dreamlake-py` (`dreamlake.cli`),
8
- built on Commander + native `fetch`, pure ESM, with the token saved to a flat
9
- YAML file instead of the system keyring.
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` entry
12
- > point — this CLI is the TypeScript replacement. When both are on a machine,
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
- dreamlake login --env prod
36
- dreamlake list project
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 install
184
- pnpm cli --help # dev runs the TS source through tsx
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
- let tsx;
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
- // Resolves to the package root from both src/cli/ (dev) and dist/cli/ (published).
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 ?? (opts.file ? readFile(opts.file) : undefined);
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. '{\"index_html\":\"https://...\"}'");
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>", 'JSON object of artifact URLs, e.g. \'{"index_html":"https://..."}\'')
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.1.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.