@dreamlake/ml-dash 0.1.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.
@@ -0,0 +1,661 @@
1
+ /**
2
+ * `ml-dash download` — pull experiments from a server into a local `.dash` tree.
3
+ *
4
+ * The flag surface and the discover → skip → (dry run) → download shape follow
5
+ * the Python command. Four behaviours differ, each because the Python version
6
+ * reported success while writing nothing:
7
+ *
8
+ * 1. **The local writes actually happen.** Every `self.local.*` call in the
9
+ * Python downloader passed `project=`/`experiment=` keywords to a
10
+ * `LocalStorage` whose signature is `(owner, project, prefix, …)`. Each one
11
+ * raised `TypeError`, each was swallowed by its section's `except`, and
12
+ * `download_experiment` still set `success = True` — so a download produced
13
+ * an experiment.json and nothing else, and exited 0.
14
+ * 2. **File fields are read from where the server puts them.** The GraphQL
15
+ * file page returns `name`/`pPath`/`physicalFile{…}`; Python indexed
16
+ * `file_info["filename"]`, `["path"]`, `["checksum"]`, so every file failed
17
+ * with a KeyError even had the write worked.
18
+ * 3. **Checksums are verified.** A file whose SHA-256 does not match the
19
+ * server's is deleted rather than written into the tree, and the run exits
20
+ * non-zero. Silent corruption in an archive is worse than a failed pull.
21
+ * 4. **Parameters and metrics are not gated on the list query.** `has_params`
22
+ * came from a `parameters` field the list query never selects, so a
23
+ * multi-experiment download skipped every parameter set. Here they are
24
+ * fetched unless `--skip-params`, and an empty result simply writes nothing.
25
+ *
26
+ * The "already exists locally" check also uses the prefix path the downloader
27
+ * writes to, not `root/project/experiment`, which never matched a real tree.
28
+ */
29
+ import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import path from "node:path";
32
+ import { makeClient, notAuthenticatedMessage, resolveContext } from "../cli/context.js";
33
+ import { LocalStorage, sha256File } from "../local/storage.js";
34
+ import { bold, cyan, dim, formatBytes, green, red, renderTable, yellow } from "../util/ansi.js";
35
+ import { hasWildcard } from "../util/glob.js";
36
+ import { asId } from "../util/json.js";
37
+ import { runPool } from "../util/pool.js";
38
+ export const spec = {
39
+ name: "download",
40
+ help: "Download experiments from remote server to local storage",
41
+ description: `Download experiments from a remote ML-Dash server into local storage.
42
+
43
+ Examples:
44
+ ml-dash download -p alice/my-project
45
+ ml-dash download ./backup -p alice/my-project --experiment exp-one
46
+ ml-dash download -p 'alice/tut*' --dry-run
47
+ ml-dash download --tracks alice/proj/exp/robot/position -f jsonl -o joints.jsonl`,
48
+ positionals: [
49
+ { dest: "path", metavar: "PATH", default: "./.dash", help: "Local storage directory (default: ./.dash)" },
50
+ ],
51
+ options: [
52
+ { flags: ["--tracks"], dest: "tracks", metavar: "PATH", help: "Download track data from path (e.g., 'namespace/project/exp/robot/position')" },
53
+ { flags: ["-f", "--format"], dest: "format", metavar: "FORMAT", choices: ["json", "jsonl", "parquet", "mcap"], help: "Track export format (default: jsonl)" },
54
+ { flags: ["-o", "--output"], dest: "output", metavar: "FILE", help: "Output file path (default: auto-generated from topic)" },
55
+ { flags: ["--dash-url", "--api-url"], dest: "dash_url", metavar: "URL", help: "ML-Dash server URL (defaults to config or https://api.dash.ml)" },
56
+ { flags: ["-p", "--pref", "--prefix", "--proj", "--project"], dest: "project", metavar: "PROJECT", help: "Filter experiments by project or pattern (supports glob: 'tut*', 'tom*/tutorials/*')" },
57
+ { flags: ["--experiment"], dest: "experiment", metavar: "NAME", help: "Download specific experiment (requires --project)" },
58
+ { flags: ["--skip-logs"], dest: "skip_logs", boolean: true, help: "Don't download logs" },
59
+ { flags: ["--skip-metrics"], dest: "skip_metrics", boolean: true, help: "Don't download metrics" },
60
+ { flags: ["--skip-files"], dest: "skip_files", boolean: true, help: "Don't download files" },
61
+ { flags: ["--skip-params"], dest: "skip_params", boolean: true, help: "Don't download parameters" },
62
+ { flags: ["--dry-run"], dest: "dry_run", boolean: true, help: "Preview without downloading" },
63
+ { flags: ["--overwrite"], dest: "overwrite", boolean: true, help: "Overwrite existing experiments" },
64
+ { flags: ["--resume"], dest: "resume", boolean: true, help: "Resume interrupted download" },
65
+ { flags: ["--state-file"], dest: "state_file", metavar: "FILE", help: "State file path for resume (default: .dash-download-state.json)" },
66
+ { flags: ["--batch-size"], dest: "batch_size", metavar: "N", help: "Batch size for logs/metrics (default: 1000, max: 10000)" },
67
+ { flags: ["--max-concurrent-metrics"], dest: "max_concurrent_metrics", metavar: "N", help: "Parallel metric downloads (default: 5)" },
68
+ { flags: ["--max-concurrent-files"], dest: "max_concurrent_files", metavar: "N", help: "Parallel file downloads (default: 3)" },
69
+ { flags: ["-v", "--verbose"], dest: "verbose", boolean: true, help: "Detailed progress output" },
70
+ ],
71
+ };
72
+ function fromGraphql(data) {
73
+ const metadata = data?.metadata ?? {};
74
+ return {
75
+ project: data?.project?.slug ?? "unknown",
76
+ experiment: data.name,
77
+ experimentId: asId(data.id),
78
+ owner: data?.project?.namespace?.slug,
79
+ prefix: typeof metadata?.prefix === "string" ? metadata.prefix : undefined,
80
+ description: data?.description ?? null,
81
+ tags: data?.tags ?? [],
82
+ metricNames: (data?.metrics ?? []).map((m) => m.name),
83
+ logCount: Number(data?.logMetadata?.totalLogs ?? 0),
84
+ fileCount: (data?.files ?? []).length,
85
+ status: data?.status ?? "RUNNING",
86
+ };
87
+ }
88
+ async function discoverExperiments(client, projectFilter, experimentFilter) {
89
+ // `-p ns/project` names a project; the server's project slug is its last
90
+ // segment, so a namespaced filter has to be split before it is used.
91
+ const projectSlug = projectFilter?.replace(/^\/+|\/+$/g, "").split("/").slice(1).join("/");
92
+ if (projectFilter && experimentFilter) {
93
+ const exp = await client.getExperimentGraphql(projectSlug || projectFilter, experimentFilter);
94
+ return exp ? [fromGraphql(exp)] : [];
95
+ }
96
+ if (projectFilter && hasWildcard(projectFilter)) {
97
+ const pattern = projectFilter.includes("/") ? projectFilter : `*/${projectFilter}/*`;
98
+ const result = await client.searchExperimentsGraphql(pattern);
99
+ return result.experiments.map(fromGraphql);
100
+ }
101
+ if (projectFilter) {
102
+ const result = await client.listExperimentsGraphql(projectSlug || projectFilter);
103
+ return result.experiments.map(fromGraphql);
104
+ }
105
+ const { projects } = await client.listProjectsGraphql();
106
+ const all = [];
107
+ for (const project of projects) {
108
+ const result = await client.listExperimentsGraphql(project.slug);
109
+ all.push(...result.experiments.map(fromGraphql));
110
+ }
111
+ return all;
112
+ }
113
+ /** Where an experiment lands under the .dash root. */
114
+ export function localPrefixFor(exp) {
115
+ if (exp.prefix)
116
+ return exp.prefix.replace(/^\/+|\/+$/g, "");
117
+ if (exp.owner)
118
+ return `${exp.owner}/${exp.project}/${exp.experiment}`;
119
+ return `${exp.project}/${exp.experiment}`;
120
+ }
121
+ const loadState = (file) => {
122
+ if (!existsSync(file))
123
+ return null;
124
+ try {
125
+ const data = JSON.parse(readFileSync(file, "utf8"));
126
+ if (typeof data?.remote_url !== "string")
127
+ return null;
128
+ return {
129
+ remote_url: data.remote_url,
130
+ local_path: data.local_path ?? "",
131
+ completed_experiments: data.completed_experiments ?? [],
132
+ failed_experiments: data.failed_experiments ?? [],
133
+ in_progress_experiment: data.in_progress_experiment ?? null,
134
+ timestamp: data.timestamp ?? null,
135
+ };
136
+ }
137
+ catch (e) {
138
+ console.log(yellow(`Warning: Could not load state file: ${e.message}`));
139
+ return null;
140
+ }
141
+ };
142
+ const saveState = (file, state) => {
143
+ state.timestamp = new Date().toISOString();
144
+ writeFileSync(file, JSON.stringify(state, null, 2));
145
+ };
146
+ async function downloadExperiment(storage, client, exp, opts) {
147
+ const result = {
148
+ experiment: `${exp.project}/${exp.experiment}`,
149
+ success: false,
150
+ downloaded: {},
151
+ failed: {},
152
+ errors: [],
153
+ bytesDownloaded: 0,
154
+ };
155
+ const prefix = localPrefixFor(exp);
156
+ const note = (line) => {
157
+ if (opts.verbose)
158
+ console.log(line);
159
+ };
160
+ try {
161
+ note(dim(` Downloading ${exp.project}/${exp.experiment}`));
162
+ storage.createExperiment({
163
+ project: exp.project,
164
+ prefix,
165
+ description: exp.description,
166
+ tags: exp.tags,
167
+ bindrs: [],
168
+ metadata: null,
169
+ });
170
+ if (!opts.skipParams) {
171
+ try {
172
+ const params = await client.getParameters(exp.experimentId);
173
+ if (params && Object.keys(params).length > 0) {
174
+ storage.writeParameters(prefix, params);
175
+ result.downloaded.parameters = Object.keys(params).length;
176
+ result.bytesDownloaded += Buffer.byteLength(JSON.stringify(params), "utf8");
177
+ note(` ${green("✓")} ${result.downloaded.parameters} parameters`);
178
+ }
179
+ }
180
+ catch (e) {
181
+ (result.failed.parameters ??= []).push(e.message);
182
+ }
183
+ }
184
+ if (!opts.skipLogs) {
185
+ try {
186
+ let offset = 0;
187
+ let total = 0;
188
+ for (;;) {
189
+ const page = await client.queryLogs(exp.experimentId, {
190
+ limit: opts.batchSize,
191
+ offset,
192
+ orderBy: "sequenceNumber",
193
+ order: "asc",
194
+ });
195
+ const logs = page?.logs ?? [];
196
+ if (logs.length === 0)
197
+ break;
198
+ storage.appendLogs(prefix, logs.map((log) => ({
199
+ message: log.message,
200
+ level: log.level,
201
+ timestamp: log.timestamp,
202
+ metadata: log.metadata,
203
+ })));
204
+ total += logs.length;
205
+ result.bytesDownloaded += logs.reduce((n, log) => n + Buffer.byteLength(JSON.stringify(log), "utf8"), 0);
206
+ if (!page?.hasMore)
207
+ break;
208
+ offset += logs.length;
209
+ }
210
+ result.downloaded.logs = total;
211
+ note(` ${green("✓")} ${total} log entries`);
212
+ }
213
+ catch (e) {
214
+ (result.failed.logs ??= []).push(e.message);
215
+ }
216
+ }
217
+ if (!opts.skipMetrics) {
218
+ let names = exp.metricNames;
219
+ if (names.length === 0) {
220
+ // The list query only carries names when the server filled them in;
221
+ // asking directly is what keeps a metric from being skipped silently.
222
+ try {
223
+ names = (await client.listMetrics(exp.experimentId))
224
+ .map((m) => m?.name)
225
+ .filter((n) => typeof n === "string");
226
+ }
227
+ catch (e) {
228
+ (result.failed.metrics ??= []).push(e.message);
229
+ names = [];
230
+ }
231
+ }
232
+ if (names.length > 0) {
233
+ const outcomes = await runPool(names, opts.maxConcurrentMetrics, (name) => downloadMetric(storage, client, exp.experimentId, prefix, name, opts.batchSize));
234
+ let ok = 0;
235
+ outcomes.forEach((outcome, i) => {
236
+ if (outcome.error) {
237
+ (result.failed.metrics ??= []).push(`${names[i]}: ${outcome.error.message}`);
238
+ return;
239
+ }
240
+ ok++;
241
+ result.bytesDownloaded += outcome.value.bytes;
242
+ note(` ${green("✓")} metric '${names[i]}': ${outcome.value.points} points`);
243
+ });
244
+ result.downloaded.metrics = ok;
245
+ }
246
+ }
247
+ if (!opts.skipFiles) {
248
+ await downloadFiles(storage, client, exp, prefix, result, opts);
249
+ }
250
+ const failedSections = Object.keys(result.failed);
251
+ if (failedSections.length > 0) {
252
+ result.success = false;
253
+ result.errors.push(`${failedSections.join(", ")} failed: ` +
254
+ failedSections.flatMap((s) => result.failed[s]).slice(0, 3).join("; "));
255
+ }
256
+ else {
257
+ result.success = true;
258
+ }
259
+ }
260
+ catch (e) {
261
+ result.success = false;
262
+ result.errors.push(e.message);
263
+ }
264
+ return result;
265
+ }
266
+ /**
267
+ * One metric, chunks first.
268
+ *
269
+ * Sealed chunks come down in parallel and the still-open buffer is fetched
270
+ * separately; if any of that fails the whole metric falls back to index
271
+ * pagination, which every server supports.
272
+ */
273
+ async function downloadMetric(storage, client, experimentId, prefix, metricName, batchSize) {
274
+ try {
275
+ const stats = await client.getMetricStats(experimentId, metricName);
276
+ const totalChunks = Number(stats?.totalChunks ?? 0);
277
+ const buffered = Number(stats?.bufferedDataPoints ?? 0);
278
+ const all = [];
279
+ if (totalChunks > 0) {
280
+ const indices = Array.from({ length: totalChunks }, (_, i) => i);
281
+ const outcomes = await runPool(indices, Math.min(10, totalChunks), async (i) => {
282
+ const chunk = await client.downloadMetricChunk(experimentId, metricName, i);
283
+ return chunk?.data ?? [];
284
+ });
285
+ for (const outcome of outcomes) {
286
+ if (outcome.error)
287
+ throw outcome.error;
288
+ all.push(...outcome.value);
289
+ }
290
+ }
291
+ if (buffered > 0) {
292
+ const response = await client.getMetricData(experimentId, metricName, { bufferOnly: true });
293
+ all.push(...(response?.data ?? []));
294
+ }
295
+ if (totalChunks === 0 && buffered === 0) {
296
+ throw new Error("no chunk or buffer counts reported");
297
+ }
298
+ all.sort((a, b) => Number(a?.index ?? 0) - Number(b?.index ?? 0));
299
+ let bytes = 0;
300
+ for (let i = 0; i < all.length; i += 10_000) {
301
+ const batch = all.slice(i, i + 10_000);
302
+ storage.appendBatchToMetric(prefix, metricName, batch.map((d) => d.data));
303
+ bytes += batch.reduce((n, d) => n + Buffer.byteLength(JSON.stringify(d), "utf8"), 0);
304
+ }
305
+ return { points: all.length, bytes };
306
+ }
307
+ catch {
308
+ let startIndex = 0;
309
+ let points = 0;
310
+ let bytes = 0;
311
+ for (;;) {
312
+ const response = await client.getMetricData(experimentId, metricName, {
313
+ startIndex,
314
+ limit: batchSize,
315
+ });
316
+ const data = response?.data ?? [];
317
+ if (data.length === 0)
318
+ break;
319
+ storage.appendBatchToMetric(prefix, metricName, data.map((d) => d.data));
320
+ points += data.length;
321
+ bytes += data.reduce((n, d) => n + Buffer.byteLength(JSON.stringify(d), "utf8"), 0);
322
+ if (!response?.hasMore)
323
+ break;
324
+ startIndex += data.length;
325
+ }
326
+ return { points, bytes };
327
+ }
328
+ }
329
+ /** Flatten a GraphQL file node into the fields local storage records. */
330
+ function normalizeFile(node) {
331
+ const physical = node?.physicalFile ?? {};
332
+ return {
333
+ id: asId(node?.id),
334
+ filename: physical.filename ?? node?.name ?? "unnamed",
335
+ filePath: typeof node?.pPath === "string" ? node.pPath.replace(/^\/+/, "") : "",
336
+ description: node?.description ?? null,
337
+ tags: node?.tags ?? [],
338
+ metadata: node?.metadata ?? null,
339
+ checksum: physical.checksum ?? "",
340
+ contentType: physical.contentType ?? "",
341
+ sizeBytes: physical.sizeBytes != null ? Number(physical.sizeBytes) : 0,
342
+ };
343
+ }
344
+ async function downloadFiles(storage, client, exp, prefix, result, opts) {
345
+ const limit = 500;
346
+ let offset = 0;
347
+ for (;;) {
348
+ let page;
349
+ try {
350
+ page = await client.listFiles(exp.experimentId, limit, offset);
351
+ }
352
+ catch (e) {
353
+ (result.failed.files ??= []).push(`List files failed: ${e.message}`);
354
+ return;
355
+ }
356
+ const nodes = page?.files ?? [];
357
+ if (nodes.length === 0)
358
+ break;
359
+ const files = nodes.map(normalizeFile);
360
+ const outcomes = await runPool(files, opts.maxConcurrentFiles, (file) => downloadSingleFile(storage, client, prefix, exp.project, file));
361
+ outcomes.forEach((outcome, i) => {
362
+ if (outcome.error) {
363
+ (result.failed.files ??= []).push(`${files[i].filename}: ${outcome.error.message}`);
364
+ return;
365
+ }
366
+ result.downloaded.files = (result.downloaded.files ?? 0) + 1;
367
+ result.bytesDownloaded += outcome.value;
368
+ if (opts.verbose)
369
+ console.log(` ${green("✓")} ${files[i].filename}`);
370
+ });
371
+ if (!page?.hasMore)
372
+ break;
373
+ offset += limit;
374
+ }
375
+ }
376
+ async function downloadSingleFile(storage, client, prefix, project, file) {
377
+ const scratch = mkdtempSync(path.join(tmpdir(), "ml-dash-download-"));
378
+ // A fixed local name: the server's filename decides where the file lands in
379
+ // the .dash tree (checked there), never where the unverified bytes land
380
+ // here, so a '../' in it cannot write over anything outside the scratch dir.
381
+ const temp = path.join(scratch, "payload");
382
+ try {
383
+ await client.downloadFileStreaming(file.id, temp);
384
+ if (file.checksum) {
385
+ const actual = await sha256File(temp);
386
+ if (actual !== file.checksum) {
387
+ // Deleted, not stored: a corrupt archive entry that looks complete is
388
+ // worse than a download that says it failed.
389
+ throw new Error(`checksum mismatch (expected ${file.checksum}, got ${actual})`);
390
+ }
391
+ }
392
+ await storage.writeFile({
393
+ prefix,
394
+ project,
395
+ sourcePath: temp,
396
+ path: file.filePath,
397
+ filename: file.filename,
398
+ description: file.description,
399
+ tags: file.tags,
400
+ metadata: file.metadata,
401
+ checksum: file.checksum,
402
+ contentType: file.contentType,
403
+ sizeBytes: file.sizeBytes,
404
+ });
405
+ return file.sizeBytes;
406
+ }
407
+ finally {
408
+ rmSync(scratch, { recursive: true, force: true });
409
+ }
410
+ }
411
+ // ── track download ───────────────────────────────────────────────────────────
412
+ async function downloadTrack(args) {
413
+ const ctx = resolveContext(args);
414
+ const trackPath = String(args.tracks).replace(/^\/+|\/+$/g, "");
415
+ const format = typeof args.format === "string" ? args.format : "jsonl";
416
+ const parts = trackPath.split("/");
417
+ if (parts.length < 4) {
418
+ console.error(`${red("Error:")} Track path must be in format: 'namespace/project/experiment/topic'`);
419
+ console.error("Examples:");
420
+ console.error(" geyang/project/experiment/position");
421
+ console.error(" geyang/project/experiment/robot/position");
422
+ console.error(" geyang/project/folder/experiment/robot/camera/position");
423
+ return 1;
424
+ }
425
+ if (!ctx.apiKey) {
426
+ console.error(`${red("Error:")} ${notAuthenticatedMessage(ctx)}`);
427
+ return 1;
428
+ }
429
+ const [namespace, project] = parts;
430
+ const client = makeClient(ctx, namespace);
431
+ // Experiments can sit under folders, so the split between the experiment
432
+ // path and the topic is found by trying each one.
433
+ let experiment = null;
434
+ let experimentName = "";
435
+ let topic = "";
436
+ const tried = [];
437
+ for (let split = 3; split <= parts.length; split++) {
438
+ const candidateName = parts.slice(2, split).join("/");
439
+ const candidateTopic = parts.slice(split).join("/");
440
+ if (!candidateTopic)
441
+ continue;
442
+ tried.push(candidateName);
443
+ for (const lookup of [
444
+ () => client.getExperimentByPathGraphql(project, candidateName, namespace),
445
+ () => client.getExperimentGraphql(project, candidateName, namespace),
446
+ ]) {
447
+ try {
448
+ const found = await lookup();
449
+ if (found) {
450
+ experiment = found;
451
+ experimentName = candidateName;
452
+ topic = candidateTopic;
453
+ break;
454
+ }
455
+ }
456
+ catch {
457
+ // Try the next lookup, then the next split point.
458
+ }
459
+ }
460
+ if (experiment)
461
+ break;
462
+ }
463
+ if (!experiment) {
464
+ console.error(`${red("Error:")} Could not find valid experiment in path: ${trackPath}`);
465
+ console.error("\nTried the following experiment names:");
466
+ for (const name of tried)
467
+ console.error(` - ${name}`);
468
+ console.error(`\nMake sure the experiment exists in project '${project}'`);
469
+ return 1;
470
+ }
471
+ console.log(bold("Downloading track data..."));
472
+ console.log(` Namespace: ${namespace}`);
473
+ console.log(` Project: ${project}`);
474
+ console.log(` Experiment: ${experimentName}`);
475
+ console.log(` Topic: ${topic}`);
476
+ console.log(` Format: ${format}`);
477
+ try {
478
+ console.log(`\n${cyan("Fetching track data from server...")}`);
479
+ const data = await client.getTrackData(String(experiment.id), topic, format);
480
+ const output = path.resolve(typeof args.output === "string" ? args.output : `${topic.replace(/\//g, "_")}.${format}`);
481
+ if (Buffer.isBuffer(data))
482
+ writeFileSync(output, data);
483
+ else
484
+ writeFileSync(output, JSON.stringify(data, null, 2));
485
+ const size = Buffer.isBuffer(data)
486
+ ? data.length
487
+ : Buffer.byteLength(JSON.stringify(data, null, 2), "utf8");
488
+ console.log(`\n${green("✓ Track data downloaded successfully")}`);
489
+ console.log(` Output: ${output}`);
490
+ console.log(` Size: ${formatBytes(size)}`);
491
+ console.log(` Format: ${format}`);
492
+ if (format === "json" && !Buffer.isBuffer(data)) {
493
+ const count = data.count ?? (data.entries ?? []).length;
494
+ if (count)
495
+ console.log(` Entries: ${count}`);
496
+ }
497
+ return 0;
498
+ }
499
+ catch (e) {
500
+ console.error(`${red("Error downloading track data:")} ${e.message}`);
501
+ return 1;
502
+ }
503
+ }
504
+ // ── entry point ──────────────────────────────────────────────────────────────
505
+ const positiveInt = (value, fallback) => {
506
+ if (value === undefined)
507
+ return fallback;
508
+ const n = Number.parseInt(String(value), 10);
509
+ return Number.isFinite(n) && n > 0 ? n : Number.NaN;
510
+ };
511
+ export async function run(args) {
512
+ if (args.tracks)
513
+ return downloadTrack(args);
514
+ const ctx = resolveContext(args);
515
+ const batchSize = positiveInt(args.batch_size, 1000);
516
+ const maxMetrics = positiveInt(args.max_concurrent_metrics, 5);
517
+ const maxFiles = positiveInt(args.max_concurrent_files, 3);
518
+ for (const [flag, value] of [
519
+ ["--batch-size", batchSize],
520
+ ["--max-concurrent-metrics", maxMetrics],
521
+ ["--max-concurrent-files", maxFiles],
522
+ ]) {
523
+ if (!Number.isFinite(value)) {
524
+ console.error(`${red("Error:")} ${flag} must be a positive integer`);
525
+ return 1;
526
+ }
527
+ }
528
+ const projectFilter = typeof args.project === "string" ? args.project : undefined;
529
+ const namespace = projectFilter?.replace(/^\/+|\/+$/g, "").split("/")[0];
530
+ if (!projectFilter || projectFilter.replace(/^\/+|\/+$/g, "").split("/").length < 2) {
531
+ console.error(`${red("Error:")} --project must be in format 'namespace/project' or 'namespace/project/exp'`);
532
+ console.error("Example: ml-dash download --project alice/my-project");
533
+ return 1;
534
+ }
535
+ if (!ctx.apiKey) {
536
+ console.error(`${red("Error:")} ${notAuthenticatedMessage(ctx)}`);
537
+ return 1;
538
+ }
539
+ const localPath = path.resolve(String(args.path ?? "./.dash"));
540
+ const client = makeClient(ctx, namespace);
541
+ const storage = new LocalStorage(localPath);
542
+ const stateFile = path.resolve(String(args.state_file ?? ".dash-download-state.json"));
543
+ let state = args.resume ? loadState(stateFile) : null;
544
+ if (args.resume) {
545
+ if (state) {
546
+ console.log(cyan(`Resuming from previous download (${state.completed_experiments.length} completed)`));
547
+ }
548
+ else {
549
+ console.log(yellow("No previous state found, starting fresh"));
550
+ }
551
+ }
552
+ state ??= {
553
+ remote_url: ctx.remoteUrl,
554
+ local_path: localPath,
555
+ completed_experiments: [],
556
+ failed_experiments: [],
557
+ in_progress_experiment: null,
558
+ timestamp: null,
559
+ };
560
+ console.log(bold("Discovering experiments on remote server..."));
561
+ let experiments;
562
+ try {
563
+ experiments = await discoverExperiments(client, projectFilter, typeof args.experiment === "string" ? args.experiment : undefined);
564
+ }
565
+ catch (e) {
566
+ console.error(red(`Failed to discover experiments: ${e.message}`));
567
+ return 1;
568
+ }
569
+ if (experiments.length === 0) {
570
+ console.log(yellow("No experiments found"));
571
+ return 0;
572
+ }
573
+ console.log(`Found ${experiments.length} experiment(s)`);
574
+ const queued = [];
575
+ for (const exp of experiments) {
576
+ const key = `${exp.project}/${exp.experiment}`;
577
+ if (state.completed_experiments.includes(key) && !args.overwrite) {
578
+ console.log(dim(` Skipping ${key} (already completed)`));
579
+ continue;
580
+ }
581
+ // A prefix that cannot be resolved under the root is not "already here":
582
+ // it is queued so the per-experiment path reports it as the failure it is,
583
+ // rather than aborting the whole run from inside a skip check.
584
+ let expJson = null;
585
+ try {
586
+ expJson = path.join(storage.experimentDir(localPrefixFor(exp)), "experiment.json");
587
+ }
588
+ catch {
589
+ expJson = null;
590
+ }
591
+ if (expJson && existsSync(expJson) && !args.overwrite) {
592
+ console.log(yellow(` Skipping ${key} (already exists locally)`));
593
+ continue;
594
+ }
595
+ queued.push(exp);
596
+ }
597
+ if (queued.length === 0) {
598
+ console.log(green("All experiments already downloaded"));
599
+ return 0;
600
+ }
601
+ if (args.dry_run) {
602
+ console.log(`\n${bold("Dry run - would download:")}`);
603
+ for (const exp of queued) {
604
+ console.log(` • ${exp.project}/${exp.experiment}`);
605
+ console.log(` Logs: ${exp.logCount}, Metrics: ${exp.metricNames.length}, Files: ${exp.fileCount}`);
606
+ }
607
+ return 0;
608
+ }
609
+ const opts = {
610
+ batchSize,
611
+ skipLogs: args.skip_logs === true,
612
+ skipMetrics: args.skip_metrics === true,
613
+ skipFiles: args.skip_files === true,
614
+ skipParams: args.skip_params === true,
615
+ verbose: args.verbose === true,
616
+ maxConcurrentMetrics: maxMetrics,
617
+ maxConcurrentFiles: maxFiles,
618
+ };
619
+ console.log(`\n${bold(`Downloading ${queued.length} experiment(s)...`)}`);
620
+ const started = Date.now();
621
+ const results = [];
622
+ for (let i = 0; i < queued.length; i++) {
623
+ const exp = queued[i];
624
+ const key = `${exp.project}/${exp.experiment}`;
625
+ console.log(`\n${cyan(`[${i + 1}/${queued.length}] ${key}`)}`);
626
+ state.in_progress_experiment = key;
627
+ saveState(stateFile, state);
628
+ const result = await downloadExperiment(storage, client, exp, opts);
629
+ results.push(result);
630
+ if (result.success) {
631
+ state.completed_experiments.push(key);
632
+ console.log(` ${green("✓ Downloaded successfully")}`);
633
+ }
634
+ else {
635
+ state.failed_experiments.push(key);
636
+ console.log(` ${red(`✗ Failed: ${result.errors.join(", ")}`)}`);
637
+ }
638
+ state.in_progress_experiment = null;
639
+ saveState(stateFile, state);
640
+ }
641
+ const elapsed = (Date.now() - started) / 1000;
642
+ const totalBytes = results.reduce((n, r) => n + r.bytesDownloaded, 0);
643
+ const successful = results.filter((r) => r.success).length;
644
+ const rows = [
645
+ ["Total Experiments", String(results.length)],
646
+ ["Successful", String(successful)],
647
+ ["Failed", String(results.length - successful)],
648
+ ["Total Data", formatBytes(totalBytes)],
649
+ ["Total Time", `${elapsed.toFixed(2)}s`],
650
+ ];
651
+ if (elapsed > 0)
652
+ rows.push(["Avg Speed", `${formatBytes(totalBytes / elapsed)}/s`]);
653
+ console.log(`\n${bold("Download Summary")}`);
654
+ console.log(renderTable([{ header: "Metric" }, { header: "Value" }], rows));
655
+ if (successful === results.length) {
656
+ if (existsSync(stateFile))
657
+ unlinkSync(stateFile);
658
+ return 0;
659
+ }
660
+ return 1;
661
+ }