@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,786 @@
1
+ /**
2
+ * `ml-dash upload` — push a local `.dash` tree to a server.
3
+ *
4
+ * The shape of the Python command is preserved: discover → validate → (dry
5
+ * run) → upload, with a resume state file and per-section skip flags. Three
6
+ * behaviours differ from the Python implementation on purpose, because each
7
+ * one silently dropped data there:
8
+ *
9
+ * 1. **Both metric layouts are uploaded.** Python's discovery only looked at
10
+ * `metrics/<name>/data.jsonl`, so an experiment whose metrics were written
11
+ * by `write_metric_data` (the flat `metrics/<name>.jsonl` form) uploaded as
12
+ * a success with none of its metrics. Discovery here goes through
13
+ * `LocalStorage.listMetrics`, which reads both.
14
+ * 2. **Files are read from where they are.** Python used the *declared*
15
+ * `prefix` in experiment.json to locate the files directory, and skipped
16
+ * file upload entirely when that prefix had fewer than three segments.
17
+ * Here the on-disk location is used for reading and the declared prefix is
18
+ * sent to the server, so a short or stale prefix costs a server path, not
19
+ * the files.
20
+ * 3. **A section that fails fails the experiment.** Python recorded a failed
21
+ * log/metric/file batch in `result.failed` and still set `success = True`,
22
+ * so a run that uploaded metadata and lost every metric exited 0 and
23
+ * deleted its own resume state. Here any failed section marks the
24
+ * experiment failed, which keeps the state file and exits 1.
25
+ */
26
+ import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
27
+ import path from "node:path";
28
+ import { makeClient, notAuthenticatedMessage, resolveContext } from "../cli/context.js";
29
+ import { LocalStorage } from "../local/storage.js";
30
+ import { bold, cyan, dim, formatBytes, green, red, renderTable, yellow } from "../util/ansi.js";
31
+ import { fnmatch, hasWildcard } from "../util/glob.js";
32
+ import { parseJson } from "../util/json.js";
33
+ import { runPool } from "../util/pool.js";
34
+ /** Metric uploads run in parallel, as `ThreadPoolExecutor(max_workers=5)` did. */
35
+ const METRIC_WORKERS = 5;
36
+ export const spec = {
37
+ name: "upload",
38
+ help: "Upload local experiments to remote server",
39
+ description: `Upload locally-stored ML-Dash experiment data to a remote server.
40
+
41
+ Examples:
42
+ ml-dash upload
43
+ ml-dash upload ./.dash -p 'tom/*/exp*'
44
+ ml-dash upload --dry-run -v
45
+ ml-dash upload -t alice/shared-project
46
+ ml-dash upload --tracks robot_position.jsonl --remote-path tom/proj/exp/robot/position`,
47
+ positionals: [
48
+ { dest: "path", metavar: "PATH", default: "./.dash", help: "Local storage directory to upload from (default: ./.dash)" },
49
+ ],
50
+ options: [
51
+ { flags: ["--dash-url", "--api-url"], dest: "dash_url", metavar: "URL", help: "ML-Dash server URL (defaults to config or https://api.dash.ml)" },
52
+ { flags: ["--tracks"], dest: "tracks", metavar: "FILE", help: "Upload track data file (e.g., robot_position.jsonl). Requires --remote-path." },
53
+ { flags: ["--remote-path"], dest: "remote_path", metavar: "PATH", help: "Remote path for track (e.g., 'namespace/project/exp/robot/position')" },
54
+ { flags: ["-p", "--pref", "--prefix", "--proj", "--project"], dest: "project", metavar: "PROJECT", help: "Filter experiments by prefix pattern (supports glob: 'tom/*/exp*', 'alice/project-?/baseline')" },
55
+ { flags: ["-t", "--target"], dest: "target", metavar: "TARGET", help: "Target prefix/directory on server where experiments will be uploaded (e.g., 'alice/shared-project')" },
56
+ { flags: ["--skip-logs"], dest: "skip_logs", boolean: true, help: "Don't upload logs" },
57
+ { flags: ["--skip-metrics"], dest: "skip_metrics", boolean: true, help: "Don't upload metrics" },
58
+ { flags: ["--skip-files"], dest: "skip_files", boolean: true, help: "Don't upload files" },
59
+ { flags: ["--skip-params"], dest: "skip_params", boolean: true, help: "Don't upload parameters" },
60
+ { flags: ["--dry-run"], dest: "dry_run", boolean: true, help: "Show what would be uploaded without uploading" },
61
+ { flags: ["--strict"], dest: "strict", boolean: true, help: "Fail on any validation error (default: skip invalid data)" },
62
+ { flags: ["-v", "--verbose"], dest: "verbose", boolean: true, help: "Show detailed progress" },
63
+ { flags: ["--batch-size"], dest: "batch_size", metavar: "N", help: "Batch size for logs/metrics (default: 100)" },
64
+ { flags: ["--resume"], dest: "resume", boolean: true, help: "Resume previous interrupted upload" },
65
+ { flags: ["--state-file"], dest: "state_file", metavar: "FILE", help: "Path to state file for resume (default: .dash-upload-state.json)" },
66
+ ],
67
+ };
68
+ /** Every directory under `root` that contains an experiment.json. */
69
+ function findExperimentDirs(root) {
70
+ const out = [];
71
+ const walk = (dir, depth) => {
72
+ if (depth > 24)
73
+ return;
74
+ let entries;
75
+ try {
76
+ entries = readdirSync(dir, { withFileTypes: true });
77
+ }
78
+ catch {
79
+ return;
80
+ }
81
+ for (const entry of entries) {
82
+ if (!entry.isDirectory())
83
+ continue;
84
+ const child = path.join(dir, entry.name);
85
+ if (existsSync(path.join(child, "experiment.json")))
86
+ out.push(child);
87
+ // Descend regardless: `.dash/owner/project/folder/exp` nests experiments
88
+ // under plain folders, and an experiment may sit below another one.
89
+ walk(child, depth + 1);
90
+ }
91
+ };
92
+ walk(root, 0);
93
+ return out.sort();
94
+ }
95
+ /**
96
+ * Match the three filter modes argparse's `-p` carried in the Python CLI:
97
+ * a glob is fnmatched against the path under the root, a value with a slash is
98
+ * a path prefix, and a bare word is an exact project-name match.
99
+ */
100
+ export function matchesFilter(relPath, projectName, filter) {
101
+ if (hasWildcard(filter))
102
+ return fnmatch(relPath, filter);
103
+ if (filter.includes("/"))
104
+ return relPath === filter || relPath.startsWith(`${filter}/`);
105
+ return projectName === filter;
106
+ }
107
+ export function discoverExperiments(storage, projectFilter, experimentFilter) {
108
+ const root = storage.rootPath;
109
+ const found = [];
110
+ for (const dir of findExperimentDirs(root)) {
111
+ const relPath = path.relative(root, dir).split(path.sep).join("/");
112
+ if (!relPath || relPath.startsWith(".."))
113
+ continue;
114
+ let declaredPrefix;
115
+ try {
116
+ const meta = parseJson(readFileSync(path.join(dir, "experiment.json"), "utf8"));
117
+ if (typeof meta?.prefix === "string" && meta.prefix.trim())
118
+ declaredPrefix = meta.prefix;
119
+ }
120
+ catch {
121
+ // Unreadable metadata is a validation error, not a discovery one.
122
+ }
123
+ const relParts = relPath.split("/");
124
+ let project;
125
+ let experiment;
126
+ if (declaredPrefix) {
127
+ const parts = declaredPrefix.replace(/^\/+|\/+$/g, "").split("/");
128
+ if (parts.length < 3)
129
+ continue; // needs at least owner/project/experiment
130
+ project = parts[1];
131
+ experiment = parts[parts.length - 1];
132
+ }
133
+ else {
134
+ if (relParts.length < 2)
135
+ continue;
136
+ experiment = relParts[relParts.length - 1];
137
+ project = relParts[relParts.length - 2];
138
+ }
139
+ if (projectFilter && !matchesFilter(relPath, project, projectFilter))
140
+ continue;
141
+ if (experimentFilter && experiment !== experimentFilter)
142
+ continue;
143
+ const info = {
144
+ project,
145
+ experiment,
146
+ dir,
147
+ storagePrefix: relPath,
148
+ declaredPrefix,
149
+ hasParams: existsSync(path.join(dir, "parameters.json")),
150
+ hasLogs: existsSync(path.join(dir, "logs", "logs.jsonl")),
151
+ metrics: storage.listMetrics(relPath),
152
+ fileCount: 0,
153
+ estimatedSize: 0,
154
+ };
155
+ const records = storage.listFiles(relPath);
156
+ info.fileCount = records.length;
157
+ for (const record of records) {
158
+ try {
159
+ info.estimatedSize += statSync(storage.filePath(relPath, record)).size;
160
+ }
161
+ catch {
162
+ // Counted by validation as a missing file.
163
+ }
164
+ }
165
+ found.push(info);
166
+ }
167
+ return found;
168
+ }
169
+ export function validateExperiment(storage, info, strict) {
170
+ const result = { isValid: true, warnings: [], errors: [] };
171
+ const expJson = path.join(info.dir, "experiment.json");
172
+ if (!existsSync(expJson)) {
173
+ return { ...result, isValid: false, errors: ["Missing experiment.json"] };
174
+ }
175
+ try {
176
+ const metadata = parseJson(readFileSync(expJson, "utf8"));
177
+ if (!metadata || typeof metadata !== "object" || !("name" in metadata) || !("project" in metadata)) {
178
+ return {
179
+ ...result,
180
+ isValid: false,
181
+ errors: ["experiment.json missing required fields (name, project)"],
182
+ };
183
+ }
184
+ result.metadata = metadata;
185
+ }
186
+ catch (e) {
187
+ return { ...result, isValid: false, errors: [`Invalid JSON in experiment.json: ${e.message}`] };
188
+ }
189
+ if (info.hasParams) {
190
+ const params = storage.readParameters(info.storagePrefix);
191
+ if (params === null)
192
+ result.warnings.push("parameters.json is not a dict (will skip)");
193
+ else
194
+ result.parameters = params;
195
+ }
196
+ if (info.hasLogs) {
197
+ const invalid = countInvalidLines(path.join(info.dir, "logs", "logs.jsonl"), (o) => "message" in o);
198
+ if (invalid.count > 0) {
199
+ result.warnings.push(`logs.jsonl has ${invalid.count} invalid lines (e.g., [${invalid.preview.join(", ")}]...) - will skip these`);
200
+ }
201
+ }
202
+ for (const metric of info.metrics) {
203
+ const invalid = countInvalidLines(metric.dataFile, (o) => "data" in o);
204
+ if (invalid.count > 0) {
205
+ result.warnings.push(`metric '${metric.name}' has ${invalid.count} invalid lines (e.g., [${invalid.preview.join(", ")}]...) - will skip these`);
206
+ }
207
+ }
208
+ const missing = storage
209
+ .listFiles(info.storagePrefix)
210
+ .filter((f) => !existsSync(storage.filePath(info.storagePrefix, f)))
211
+ .map((f) => f.filename);
212
+ if (missing.length > 0) {
213
+ result.warnings.push(`${missing.length} files referenced in metadata but missing on disk ` +
214
+ `(e.g., [${missing.slice(0, 3).join(", ")}]...) - will skip these`);
215
+ }
216
+ if (strict && result.warnings.length > 0) {
217
+ result.errors.push(...result.warnings);
218
+ result.warnings = [];
219
+ result.isValid = false;
220
+ }
221
+ return result;
222
+ }
223
+ function countInvalidLines(file, isValid) {
224
+ const bad = [];
225
+ if (!existsSync(file))
226
+ return { count: 0, preview: [] };
227
+ let text;
228
+ try {
229
+ text = readFileSync(file, "utf8");
230
+ }
231
+ catch {
232
+ return { count: 0, preview: [] };
233
+ }
234
+ const lines = text.split("\n");
235
+ lines.forEach((line, i) => {
236
+ if (!line.trim())
237
+ return;
238
+ try {
239
+ const parsed = JSON.parse(line);
240
+ if (parsed === null || typeof parsed !== "object" || !isValid(parsed))
241
+ bad.push(i + 1);
242
+ }
243
+ catch {
244
+ bad.push(i + 1);
245
+ }
246
+ });
247
+ return { count: bad.length, preview: bad.slice(0, 5) };
248
+ }
249
+ const loadState = (file) => {
250
+ if (!existsSync(file))
251
+ return null;
252
+ try {
253
+ const data = JSON.parse(readFileSync(file, "utf8"));
254
+ if (typeof data?.dash_root !== "string" || typeof data?.remote_url !== "string")
255
+ return null;
256
+ return {
257
+ dash_root: data.dash_root,
258
+ remote_url: data.remote_url,
259
+ completed_experiments: data.completed_experiments ?? [],
260
+ failed_experiments: data.failed_experiments ?? [],
261
+ in_progress_experiment: data.in_progress_experiment ?? null,
262
+ timestamp: data.timestamp ?? null,
263
+ };
264
+ }
265
+ catch {
266
+ return null;
267
+ }
268
+ };
269
+ const saveState = (file, state) => {
270
+ state.timestamp = new Date().toISOString();
271
+ writeFileSync(file, JSON.stringify(state, null, 2));
272
+ };
273
+ async function uploadExperiment(storage, client, info, validation, opts) {
274
+ const result = {
275
+ experiment: `${info.project}/${info.experiment}`,
276
+ success: false,
277
+ uploaded: {},
278
+ failed: {},
279
+ errors: [],
280
+ bytesUploaded: 0,
281
+ };
282
+ const note = (line) => {
283
+ if (opts.verbose)
284
+ console.log(line);
285
+ };
286
+ try {
287
+ const meta = validation.metadata ?? {};
288
+ // --target behaves like an scp destination directory: the experiment name
289
+ // is appended to it, and its second segment names the project.
290
+ let fullPrefix;
291
+ let targetProject;
292
+ if (opts.targetPrefix) {
293
+ fullPrefix = `${opts.targetPrefix.replace(/\/+$/, "")}/${info.experiment}`;
294
+ const parts = opts.targetPrefix.replace(/^\/+|\/+$/g, "").split("/");
295
+ targetProject = parts.length >= 2 ? parts[1] : info.project;
296
+ }
297
+ else if (info.declaredPrefix) {
298
+ fullPrefix = info.declaredPrefix;
299
+ targetProject = info.project;
300
+ }
301
+ else {
302
+ fullPrefix = info.storagePrefix;
303
+ targetProject = info.project;
304
+ }
305
+ note(dim(" Creating experiment..."));
306
+ const response = await client.createOrUpdateExperiment({
307
+ project: targetProject,
308
+ name: info.experiment,
309
+ description: meta.description ?? null,
310
+ tags: meta.tags ?? null,
311
+ bindrs: meta.bindrs ?? null,
312
+ prefix: fullPrefix,
313
+ writeProtected: meta.write_protected === true,
314
+ metadata: meta.metadata ?? null,
315
+ });
316
+ const experimentId = String(response?.experiment?.id ?? response?.id ?? "");
317
+ if (!experimentId)
318
+ throw new Error("Server did not return an experiment id");
319
+ note(` ${green("✓")} Created experiment (id: ${experimentId})`);
320
+ if (!opts.skipParams && validation.parameters) {
321
+ const params = validation.parameters;
322
+ await client.setParameters(experimentId, params);
323
+ result.uploaded.params = Object.keys(params).length;
324
+ result.bytesUploaded += Buffer.byteLength(JSON.stringify(params), "utf8");
325
+ note(` ${green("✓")} Uploaded ${result.uploaded.params} parameters`);
326
+ }
327
+ if (!opts.skipLogs && info.hasLogs) {
328
+ try {
329
+ const { uploaded, skipped, bytes } = await uploadLogs(storage, client, experimentId, info, opts);
330
+ result.uploaded.logs = uploaded;
331
+ result.bytesUploaded += bytes;
332
+ note(` ${green("✓")} Uploaded ${uploaded} log entries` +
333
+ (skipped > 0 ? ` (skipped ${skipped} invalid)` : ""));
334
+ }
335
+ catch (e) {
336
+ (result.failed.logs ??= []).push(e.message);
337
+ }
338
+ }
339
+ if (!opts.skipMetrics && info.metrics.length > 0) {
340
+ const outcomes = await runPool(info.metrics, METRIC_WORKERS, async (metric) => {
341
+ const { points, skipped, bytes } = storage.readMetricPoints(metric);
342
+ for (let i = 0; i < points.length; i += opts.batchSize) {
343
+ await client.appendBatchToMetric(experimentId, metric.name, points.slice(i, i + opts.batchSize));
344
+ }
345
+ return { uploaded: points.length, skipped, bytes };
346
+ });
347
+ let ok = 0;
348
+ outcomes.forEach((outcome, i) => {
349
+ const metric = info.metrics[i];
350
+ if (outcome.error) {
351
+ (result.failed.metrics ??= []).push(`${metric.name}: ${outcome.error.message}`);
352
+ note(` ${red("✗")} Failed to upload '${metric.name}': ${outcome.error.message}`);
353
+ return;
354
+ }
355
+ ok++;
356
+ result.bytesUploaded += outcome.value.bytes;
357
+ note(` ${green("✓")} Uploaded ${outcome.value.uploaded} data points for '${metric.name}'` +
358
+ (outcome.value.skipped > 0 ? ` (skipped ${outcome.value.skipped} invalid)` : ""));
359
+ });
360
+ result.uploaded.metrics = ok;
361
+ }
362
+ if (!opts.skipFiles && info.fileCount > 0) {
363
+ const uploadedFiles = await uploadFiles(storage, client, experimentId, info, result, opts);
364
+ result.uploaded.files = uploadedFiles;
365
+ }
366
+ // A section that failed is a failed experiment: keeping success here is
367
+ // what let the Python CLI delete its resume state after losing metrics.
368
+ const failedSections = Object.keys(result.failed);
369
+ if (failedSections.length > 0) {
370
+ result.success = false;
371
+ result.errors.push(`${failedSections.join(", ")} failed: ` +
372
+ failedSections.flatMap((s) => result.failed[s]).slice(0, 3).join("; "));
373
+ }
374
+ else {
375
+ result.success = true;
376
+ }
377
+ }
378
+ catch (e) {
379
+ result.success = false;
380
+ result.errors.push(e.message);
381
+ if (opts.verbose)
382
+ console.log(` ${red(`✗ Error: ${e.message}`)}`);
383
+ }
384
+ return result;
385
+ }
386
+ async function uploadLogs(storage, client, experimentId, info, opts) {
387
+ const file = path.join(info.dir, "logs", "logs.jsonl");
388
+ const lines = readFileSync(file, "utf8").split("\n");
389
+ let batch = [];
390
+ let uploaded = 0;
391
+ let skipped = 0;
392
+ let bytes = 0;
393
+ const flush = async () => {
394
+ if (batch.length === 0)
395
+ return;
396
+ await client.createLogEntries(experimentId, batch);
397
+ uploaded += batch.length;
398
+ batch = [];
399
+ };
400
+ for (const line of lines) {
401
+ if (!line.trim())
402
+ continue;
403
+ let entry;
404
+ try {
405
+ entry = JSON.parse(line);
406
+ }
407
+ catch {
408
+ skipped++;
409
+ continue;
410
+ }
411
+ if (entry === null || typeof entry !== "object" || !("message" in entry)) {
412
+ skipped++;
413
+ continue;
414
+ }
415
+ const apiLog = {
416
+ timestamp: entry.timestamp ?? null,
417
+ level: entry.level ?? "info",
418
+ message: entry.message,
419
+ };
420
+ if ("metadata" in entry)
421
+ apiLog.metadata = entry.metadata;
422
+ batch.push(apiLog);
423
+ bytes += Buffer.byteLength(line, "utf8");
424
+ if (batch.length >= opts.batchSize)
425
+ await flush();
426
+ }
427
+ await flush();
428
+ return { uploaded, skipped, bytes };
429
+ }
430
+ async function uploadFiles(storage, client, experimentId, info, result, opts) {
431
+ let uploaded = 0;
432
+ let records;
433
+ try {
434
+ records = storage.listFiles(info.storagePrefix);
435
+ }
436
+ catch (e) {
437
+ (result.failed.files ??= []).push(e.message);
438
+ return 0;
439
+ }
440
+ if (opts.verbose) {
441
+ console.log(dim(` Found ${records.length} files to upload from prefix: ${info.storagePrefix}`));
442
+ }
443
+ for (const record of records) {
444
+ const source = storage.filePath(info.storagePrefix, record);
445
+ try {
446
+ if (!existsSync(source))
447
+ throw new Error(`missing on disk: ${source}`);
448
+ await client.uploadFile({
449
+ experimentId,
450
+ filePath: source,
451
+ prefix: record.path ?? "",
452
+ filename: record.filename,
453
+ description: record.description,
454
+ tags: record.tags,
455
+ metadata: record.metadata,
456
+ checksum: record.checksum,
457
+ contentType: record.contentType,
458
+ sizeBytes: record.sizeBytes,
459
+ });
460
+ uploaded++;
461
+ result.bytesUploaded += record.sizeBytes ?? 0;
462
+ if (opts.verbose) {
463
+ console.log(` ${green("✓")} ${record.filename} (${formatBytes(record.sizeBytes ?? 0)})`);
464
+ }
465
+ }
466
+ catch (e) {
467
+ (result.failed.files ??= []).push(`${record.filename}: ${e.message}`);
468
+ }
469
+ }
470
+ return uploaded;
471
+ }
472
+ // ── track upload ─────────────────────────────────────────────────────────────
473
+ async function uploadTrack(args) {
474
+ const ctx = resolveContext(args);
475
+ const localFile = String(args.tracks);
476
+ const remotePath = typeof args.remote_path === "string" ? args.remote_path : "";
477
+ if (!remotePath) {
478
+ console.error(`${red("Error:")} Both --tracks and --remote-path are required for track upload`);
479
+ console.error("Usage: ml-dash upload --tracks <local-file> --remote-path namespace/project/exp/topic");
480
+ return 1;
481
+ }
482
+ if (!existsSync(localFile)) {
483
+ console.error(`${red("Error:")} File not found: ${localFile}`);
484
+ return 1;
485
+ }
486
+ const parts = remotePath.replace(/^\/+|\/+$/g, "").split("/");
487
+ if (parts.length < 4) {
488
+ console.error(`${red("Error:")} Remote path must be: 'namespace/project/experiment/topic'`);
489
+ console.error("Example: geyang/project/exp1/robot/position");
490
+ return 1;
491
+ }
492
+ if (!ctx.apiKey) {
493
+ console.error(`${red("Error:")} ${notAuthenticatedMessage(ctx)}`);
494
+ return 1;
495
+ }
496
+ const [namespace, project, experimentName] = parts;
497
+ const topic = parts.slice(3).join("/");
498
+ console.log(bold("Uploading track data..."));
499
+ console.log(` Local file: ${localFile}`);
500
+ console.log(` Namespace: ${namespace}`);
501
+ console.log(` Project: ${project}`);
502
+ console.log(` Experiment: ${experimentName}`);
503
+ console.log(` Topic: ${topic}`);
504
+ const client = makeClient(ctx, namespace);
505
+ try {
506
+ const experiment = await client.getExperimentGraphql(project, experimentName, namespace);
507
+ if (!experiment) {
508
+ console.error(`${red("Error:")} Experiment '${experimentName}' not found in project '${project}'`);
509
+ return 1;
510
+ }
511
+ console.log(`\n${cyan("Reading local file...")}`);
512
+ const entries = [];
513
+ readFileSync(localFile, "utf8")
514
+ .split("\n")
515
+ .forEach((line, i) => {
516
+ if (!line.trim())
517
+ return;
518
+ try {
519
+ const entry = JSON.parse(line);
520
+ if (!entry || typeof entry !== "object" || !("timestamp" in entry)) {
521
+ console.log(`${yellow("Warning:")} Line ${i + 1} missing timestamp, skipping`);
522
+ return;
523
+ }
524
+ entries.push(entry);
525
+ }
526
+ catch (e) {
527
+ console.log(`${yellow("Warning:")} Line ${i + 1} invalid JSON: ${e.message}`);
528
+ }
529
+ });
530
+ if (entries.length === 0) {
531
+ console.error(`${red("Error:")} No valid entries found in file`);
532
+ return 1;
533
+ }
534
+ console.log(` Found ${entries.length} entries`);
535
+ console.log(`\n${cyan("Uploading to server...")}`);
536
+ const experimentId = String(experiment.id);
537
+ let total = 0;
538
+ for (let i = 0; i < entries.length; i += 1000) {
539
+ const batch = entries.slice(i, i + 1000);
540
+ await client.appendBatchToTrack(experimentId, topic, batch);
541
+ total += batch.length;
542
+ console.log(` Uploaded ${total}/${entries.length} entries`);
543
+ }
544
+ console.log(`\n${green("✓ Track data uploaded successfully")}`);
545
+ console.log(` Total entries: ${total}`);
546
+ console.log(` Topic: ${topic}`);
547
+ console.log(` Experiment: ${namespace}/${project}/${experimentName}`);
548
+ return 0;
549
+ }
550
+ catch (e) {
551
+ console.error(`${red("Error uploading track data:")} ${e.message}`);
552
+ return 1;
553
+ }
554
+ }
555
+ // ── entry point ──────────────────────────────────────────────────────────────
556
+ export async function run(args) {
557
+ if (args.tracks)
558
+ return uploadTrack(args);
559
+ const ctx = resolveContext(args);
560
+ const batchSize = Number.parseInt(String(args.batch_size ?? "100"), 10);
561
+ if (!Number.isFinite(batchSize) || batchSize <= 0) {
562
+ console.error(`${red("Error:")} --batch-size must be a positive integer`);
563
+ return 1;
564
+ }
565
+ const dashRoot = path.resolve(String(args.path ?? "./.dash"));
566
+ if (!existsSync(dashRoot)) {
567
+ console.error(`${red("Error:")} Local storage path does not exist: ${args.path}`);
568
+ return 1;
569
+ }
570
+ if (!ctx.apiKey) {
571
+ console.error(`${red("Error:")} ${notAuthenticatedMessage(ctx)}`);
572
+ return 1;
573
+ }
574
+ const stateFile = path.resolve(String(args.state_file ?? ".dash-upload-state.json"));
575
+ let state = null;
576
+ if (args.resume) {
577
+ state = loadState(stateFile);
578
+ if (!state) {
579
+ console.log(yellow("No previous upload state found. Starting fresh upload."));
580
+ }
581
+ else if (state.dash_root !== dashRoot) {
582
+ console.log(`${yellow("Warning:")} State file local path doesn't match. Starting fresh upload.`);
583
+ state = null;
584
+ }
585
+ else if (state.remote_url !== ctx.remoteUrl) {
586
+ console.log(`${yellow("Warning:")} State file remote URL doesn't match. Starting fresh upload.`);
587
+ state = null;
588
+ }
589
+ else {
590
+ console.log(green(`Resuming previous upload from ${state.timestamp}`));
591
+ console.log(` Already completed: ${state.completed_experiments.length} experiments`);
592
+ console.log(` Failed: ${state.failed_experiments.length} experiments`);
593
+ }
594
+ }
595
+ if (!state) {
596
+ state = {
597
+ dash_root: dashRoot,
598
+ remote_url: ctx.remoteUrl,
599
+ completed_experiments: [],
600
+ failed_experiments: [],
601
+ in_progress_experiment: null,
602
+ timestamp: null,
603
+ };
604
+ }
605
+ const storage = new LocalStorage(dashRoot);
606
+ console.log(`${bold("Scanning local storage:")} ${dashRoot}`);
607
+ const projectFilter = typeof args.project === "string" ? args.project : undefined;
608
+ let experiments = discoverExperiments(storage, projectFilter);
609
+ if (experiments.length === 0) {
610
+ console.log(projectFilter
611
+ ? `${yellow("No experiments found matching pattern:")} ${projectFilter}`
612
+ : yellow("No experiments found in local storage"));
613
+ return 1;
614
+ }
615
+ if (args.resume && state.completed_experiments.length > 0) {
616
+ const before = experiments.length;
617
+ const done = new Set(state.completed_experiments);
618
+ experiments = experiments.filter((e) => !done.has(`${e.project}/${e.experiment}`));
619
+ const skipped = before - experiments.length;
620
+ if (skipped > 0)
621
+ console.log(dim(`Skipping ${skipped} already completed experiment(s)`));
622
+ }
623
+ console.log(green(`Found ${experiments.length} experiment(s) to upload`));
624
+ if (args.verbose || args.dry_run) {
625
+ console.log(`\n${bold("Discovered experiments:")}`);
626
+ for (const exp of experiments) {
627
+ const parts = [];
628
+ if (exp.hasLogs)
629
+ parts.push("logs");
630
+ if (exp.hasParams)
631
+ parts.push("params");
632
+ if (exp.metrics.length)
633
+ parts.push(`${exp.metrics.length} metrics`);
634
+ if (exp.fileCount)
635
+ parts.push(`${exp.fileCount} files (${formatBytes(exp.estimatedSize)})`);
636
+ const details = parts.length ? parts.join(", ") : "metadata only";
637
+ console.log(` ${cyan("•")} ${exp.project}/${exp.experiment} ${dim(`(${details})`)}`);
638
+ }
639
+ }
640
+ if (args.dry_run) {
641
+ console.log(`\n${yellow(bold("DRY RUN"))} - No data will be uploaded`);
642
+ console.log("Run without --dry-run to proceed with upload.");
643
+ return 0;
644
+ }
645
+ console.log(`\n${bold("Validating experiments...")}`);
646
+ const validations = new Map();
647
+ const valid = [];
648
+ let invalidCount = 0;
649
+ for (const exp of experiments) {
650
+ const key = `${exp.project}/${exp.experiment}`;
651
+ const validation = validateExperiment(storage, exp, args.strict === true);
652
+ validations.set(key, validation);
653
+ if (validation.isValid)
654
+ valid.push(exp);
655
+ else
656
+ invalidCount++;
657
+ if (validation.errors.length > 0) {
658
+ console.log(` ${red("✗")} ${key}:`);
659
+ for (const error of validation.errors)
660
+ console.log(` ${red(error)}`);
661
+ }
662
+ else if (args.verbose && validation.warnings.length > 0) {
663
+ console.log(` ${yellow("⚠")} ${key}:`);
664
+ for (const warning of validation.warnings)
665
+ console.log(` ${yellow(warning)}`);
666
+ }
667
+ }
668
+ if (invalidCount > 0) {
669
+ console.log(`\n${yellow(`${invalidCount} experiment(s) failed validation and will be skipped`)}`);
670
+ if (args.strict) {
671
+ console.error(red("Error: Validation failed in --strict mode"));
672
+ return 1;
673
+ }
674
+ }
675
+ if (valid.length === 0) {
676
+ console.error(red("Error: No valid experiments to upload"));
677
+ return 1;
678
+ }
679
+ console.log(green(`${valid.length} experiment(s) ready to upload`));
680
+ const target = typeof args.target === "string" ? args.target : undefined;
681
+ let namespace;
682
+ if (target)
683
+ namespace = target.replace(/^\/+|\/+$/g, "").split("/")[0];
684
+ if (!namespace) {
685
+ const first = valid[0].declaredPrefix ?? valid[0].storagePrefix;
686
+ namespace = first.replace(/^\/+/, "").split("/")[0];
687
+ }
688
+ const client = makeClient(ctx, namespace);
689
+ const opts = {
690
+ batchSize,
691
+ skipLogs: args.skip_logs === true,
692
+ skipMetrics: args.skip_metrics === true,
693
+ skipFiles: args.skip_files === true,
694
+ skipParams: args.skip_params === true,
695
+ verbose: args.verbose === true,
696
+ targetPrefix: target,
697
+ };
698
+ console.log(`\n${bold("Uploading to:")} ${ctx.remoteUrl}`);
699
+ if (target)
700
+ console.log(`${bold("Target prefix:")} ${target}`);
701
+ const started = Date.now();
702
+ const results = [];
703
+ for (let i = 0; i < valid.length; i++) {
704
+ const exp = valid[i];
705
+ const key = `${exp.project}/${exp.experiment}`;
706
+ console.log(`${cyan(`[${i + 1}/${valid.length}] ${key}`)}`);
707
+ state.in_progress_experiment = key;
708
+ saveState(stateFile, state);
709
+ const result = await uploadExperiment(storage, client, exp, validations.get(key), opts);
710
+ results.push(result);
711
+ state.in_progress_experiment = null;
712
+ if (result.success)
713
+ state.completed_experiments.push(key);
714
+ else
715
+ state.failed_experiments.push(key);
716
+ saveState(stateFile, state);
717
+ if (!args.verbose) {
718
+ if (result.success) {
719
+ const parts = [];
720
+ if (result.uploaded.params)
721
+ parts.push(`${result.uploaded.params} params`);
722
+ if (result.uploaded.logs)
723
+ parts.push(`${result.uploaded.logs} logs`);
724
+ if (result.uploaded.metrics)
725
+ parts.push(`${result.uploaded.metrics} metrics`);
726
+ if (result.uploaded.files)
727
+ parts.push(`${result.uploaded.files} files`);
728
+ console.log(` ${green("✓")} Uploaded (${parts.length ? parts.join(", ") : "metadata only"})`);
729
+ }
730
+ else {
731
+ console.log(` ${red("✗")} Failed`);
732
+ for (const error of result.errors.slice(0, 3))
733
+ console.log(` ${red(error)}`);
734
+ }
735
+ }
736
+ }
737
+ const elapsed = (Date.now() - started) / 1000;
738
+ const totalBytes = results.reduce((n, r) => n + r.bytesUploaded, 0);
739
+ const successful = results.filter((r) => r.success);
740
+ const failed = results.filter((r) => !r.success);
741
+ const summary = [
742
+ ["Successful", `${successful.length}/${results.length}`],
743
+ ];
744
+ if (failed.length)
745
+ summary.push(["Failed", `${failed.length}/${results.length}`]);
746
+ summary.push(["Total Time", `${elapsed.toFixed(2)}s`]);
747
+ if (totalBytes > 0 && elapsed > 0) {
748
+ summary.push(["Avg Speed", `${formatBytes(totalBytes / elapsed)}/s`]);
749
+ }
750
+ console.log();
751
+ console.log(renderTable([{ header: "Status" }, { header: "Count", align: "right" }], summary, { title: "Upload Summary" }));
752
+ if (failed.length > 0) {
753
+ console.log(`\n${bold(red("Failed Experiments:"))}`);
754
+ for (const result of failed) {
755
+ console.log(` ${red("✗")} ${result.experiment}`);
756
+ for (const error of result.errors)
757
+ console.log(` ${dim(error)}`);
758
+ for (const [section, messages] of Object.entries(result.failed)) {
759
+ for (const message of messages)
760
+ console.log(` ${dim(`${section}: ${message}`)}`);
761
+ }
762
+ }
763
+ }
764
+ const totals = {
765
+ Logs: results.reduce((n, r) => n + (r.uploaded.logs ?? 0), 0),
766
+ Metrics: results.reduce((n, r) => n + (r.uploaded.metrics ?? 0), 0),
767
+ Files: results.reduce((n, r) => n + (r.uploaded.files ?? 0), 0),
768
+ };
769
+ const unit = { Logs: "entries", Metrics: "metrics", Files: "files" };
770
+ const dataRows = Object.entries(totals)
771
+ .filter(([, n]) => n > 0)
772
+ .map(([type, n]) => [type, `${n} ${unit[type]}`]);
773
+ if (dataRows.length > 0) {
774
+ console.log();
775
+ console.log(renderTable([{ header: "Type" }, { header: "Count", align: "right" }], dataRows, { title: "Data Uploaded" }));
776
+ }
777
+ if (failed.length === 0) {
778
+ if (existsSync(stateFile))
779
+ unlinkSync(stateFile);
780
+ console.log(`\n${dim("Upload complete. State file removed.")}`);
781
+ }
782
+ else {
783
+ console.log(`\n${yellow(`State saved to ${stateFile}. Use --resume to retry failed uploads.`)}`);
784
+ }
785
+ return failed.length === 0 ? 0 : 1;
786
+ }