@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.
package/dist/client.js ADDED
@@ -0,0 +1,588 @@
1
+ /**
2
+ * The ml-dash server client: REST under `{url}/api`, GraphQL at `{url}/graphql`,
3
+ * both bearing the same token.
4
+ *
5
+ * Everything moves through the server — there is no S3 direct upload, no
6
+ * presigned URL and no multipart chunking in this protocol. Files go up as one
7
+ * `multipart/form-data` POST to the unified node endpoint and come down from
8
+ * `/api/nodes/{id}/download`.
9
+ */
10
+ import { createWriteStream } from "node:fs";
11
+ import { open, stat } from "node:fs/promises";
12
+ import { Readable } from "node:stream";
13
+ import { pipeline } from "node:stream/promises";
14
+ import { asId, parseJson } from "./util/json.js";
15
+ export class AuthenticationError extends Error {
16
+ }
17
+ export class ConfigurationError extends Error {
18
+ }
19
+ export class NetworkError extends Error {
20
+ }
21
+ export class StorageError extends Error {
22
+ }
23
+ export class HttpError extends Error {
24
+ status;
25
+ url;
26
+ body;
27
+ constructor(status, url, body) {
28
+ super(`HTTP ${status} for ${url}: ${body.slice(0, 500)}`);
29
+ this.status = status;
30
+ this.url = url;
31
+ this.body = body;
32
+ }
33
+ }
34
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
35
+ export class RemoteClient {
36
+ graphqlBaseUrl;
37
+ baseUrl;
38
+ apiKey;
39
+ _namespace;
40
+ idCache = new Map();
41
+ constructor(baseUrl, namespace, apiKey) {
42
+ this.graphqlBaseUrl = baseUrl.replace(/\/+$/, "");
43
+ this.baseUrl = `${this.graphqlBaseUrl}/api`;
44
+ this._namespace = namespace;
45
+ this.apiKey = apiKey;
46
+ }
47
+ ensureAuthenticated() {
48
+ if (!this.apiKey) {
49
+ throw new AuthenticationError("Not authenticated. Run 'ml-dash login' to authenticate, or provide an explicit api key.");
50
+ }
51
+ }
52
+ /** The caller's namespace, asked of the server once and then remembered. */
53
+ async namespace() {
54
+ if (this._namespace)
55
+ return this._namespace;
56
+ const result = await this.graphqlQuery(`query GetMyNamespace { me { username } }`);
57
+ const username = result?.me?.username;
58
+ if (!username)
59
+ throw new NetworkError("Failed to fetch namespace from server");
60
+ this._namespace = username;
61
+ return username;
62
+ }
63
+ // ── transport ──────────────────────────────────────────────────────────────
64
+ async request(pathname, opts = {}) {
65
+ this.ensureAuthenticated();
66
+ const url = new URL(`${this.baseUrl}/${pathname.replace(/^\/+/, "")}`);
67
+ for (const [k, v] of Object.entries(opts.params ?? {})) {
68
+ if (v !== undefined)
69
+ url.searchParams.set(k, String(v));
70
+ }
71
+ const headers = { Authorization: `Bearer ${this.apiKey}` };
72
+ let body;
73
+ if (opts.json !== undefined) {
74
+ headers["Content-Type"] = "application/json";
75
+ body = JSON.stringify(opts.json);
76
+ }
77
+ else if (opts.form) {
78
+ body = opts.form; // fetch sets the multipart boundary itself
79
+ }
80
+ const res = await fetch(url, {
81
+ method: opts.method ?? "GET",
82
+ headers,
83
+ body,
84
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000),
85
+ });
86
+ if (opts.raw)
87
+ return res;
88
+ if (!res.ok)
89
+ throw new HttpError(res.status, url.toString(), await res.text());
90
+ return res;
91
+ }
92
+ async requestJson(pathname, opts = {}) {
93
+ const res = await this.request(pathname, opts);
94
+ const text = await res.text();
95
+ return text ? parseJson(text) : {};
96
+ }
97
+ /**
98
+ * POST that retries a 409.
99
+ *
100
+ * Node upserts are a pre-check followed by a create, so two concurrent
101
+ * requests can both pass the check and one comes back 409. Retrying is safe:
102
+ * the second attempt finds the node the first one created.
103
+ */
104
+ async postWith409Retry(pathname, opts) {
105
+ const backoff = [100, 500, 2000];
106
+ for (let attempt = 0; attempt < 3; attempt++) {
107
+ const res = await this.request(pathname, { ...opts, method: "POST", raw: true });
108
+ if (res.status !== 409) {
109
+ if (!res.ok)
110
+ throw new HttpError(res.status, pathname, await res.text());
111
+ const text = await res.text();
112
+ return text ? parseJson(text) : {};
113
+ }
114
+ await res.text();
115
+ if (attempt < 2)
116
+ await sleep(backoff[attempt]);
117
+ }
118
+ throw new HttpError(409, pathname, "node creation still conflicting after 3 attempts");
119
+ }
120
+ async graphqlQuery(query, variables = {}) {
121
+ this.ensureAuthenticated();
122
+ const res = await fetch(`${this.graphqlBaseUrl}/graphql`, {
123
+ method: "POST",
124
+ headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
125
+ body: JSON.stringify({ query, variables }),
126
+ signal: AbortSignal.timeout(30_000),
127
+ });
128
+ if (!res.ok)
129
+ throw new HttpError(res.status, `${this.graphqlBaseUrl}/graphql`, await res.text());
130
+ const result = parseJson(await res.text());
131
+ if (result.errors) {
132
+ throw new NetworkError(result.errors.map((e) => e.message ?? String(e)).join("; "));
133
+ }
134
+ return (result.data ?? {});
135
+ }
136
+ // ── identity ───────────────────────────────────────────────────────────────
137
+ async getCurrentUser() {
138
+ const result = await this.graphqlQuery(`
139
+ query GetCurrentUser {
140
+ me { id username email name given_name family_name picture }
141
+ }
142
+ `);
143
+ return result.me ?? null;
144
+ }
145
+ // ── projects ───────────────────────────────────────────────────────────────
146
+ /** Resolve a project slug to its ID. null means "not found" — the server will create it. */
147
+ async getProjectId(projectSlug) {
148
+ const ns = await this.namespace();
149
+ const cacheKey = `project:${ns}:${projectSlug}`;
150
+ const cached = this.idCache.get(cacheKey);
151
+ if (cached)
152
+ return cached;
153
+ const result = await this.graphqlQuery(`query GetProject($namespace: String!) {
154
+ namespace(slug: $namespace) { projects { id slug } }
155
+ }`, { namespace: ns });
156
+ const namespaceData = result.namespace;
157
+ if (namespaceData == null) {
158
+ throw new ConfigurationError(`Namespace '${ns}' not found. Please check the namespace exists on the server.`);
159
+ }
160
+ for (const p of namespaceData.projects ?? []) {
161
+ if (p.slug === projectSlug) {
162
+ const id = asId(p.id);
163
+ this.idCache.set(cacheKey, id);
164
+ return id;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+ async createProject(name, description) {
170
+ const ns = await this.namespace();
171
+ return this.requestJson(`namespaces/${ns}/nodes`, {
172
+ method: "POST",
173
+ json: { type: "PROJECT", name, slug: name, description: description ?? "" },
174
+ });
175
+ }
176
+ async deleteProject(projectSlug) {
177
+ const ns = await this.namespace();
178
+ const projectId = await this.getProjectId(projectSlug);
179
+ if (!projectId) {
180
+ throw new ConfigurationError(`Project '${projectSlug}' not found in namespace '${ns}'`);
181
+ }
182
+ return this.requestJson(`projects/${projectId}`, { method: "DELETE" });
183
+ }
184
+ // ── experiments ────────────────────────────────────────────────────────────
185
+ async createOrUpdateExperiment(args) {
186
+ const ns = await this.namespace();
187
+ let projectId = await this.getProjectId(args.project);
188
+ let parentId = "ROOT";
189
+ // A prefix of owner/project/folder…/name means the experiment hangs off a
190
+ // folder chain that has to exist first. Only the segments between the
191
+ // project and the experiment name are folders.
192
+ if (args.prefix) {
193
+ const parts = args.prefix.replace(/^\/+|\/+$/g, "").split("/");
194
+ const folderParts = parts.length > 3 ? parts.slice(2, -1) : [];
195
+ if (folderParts.length > 0) {
196
+ if (!projectId) {
197
+ const created = await this.requestJson(`namespaces/${ns}/nodes`, {
198
+ method: "POST",
199
+ json: { type: "PROJECT", name: args.project, slug: args.project },
200
+ });
201
+ projectId = asId(created?.project?.id) ?? null;
202
+ }
203
+ if (projectId) {
204
+ let current = "ROOT";
205
+ for (const folderName of folderParts) {
206
+ if (!folderName)
207
+ continue;
208
+ // No experimentId here: these are project-level folders, and
209
+ // tagging them with one reparents them under the experiment.
210
+ const folder = await this.postWith409Retry(`namespaces/${ns}/nodes`, {
211
+ json: { type: "FOLDER", projectId, parentId: current, name: folderName },
212
+ });
213
+ current = asId(folder?.node?.id);
214
+ }
215
+ parentId = current;
216
+ }
217
+ }
218
+ }
219
+ const payload = { type: "EXPERIMENT", name: args.name, parentId };
220
+ if (projectId)
221
+ payload.projectId = projectId;
222
+ else
223
+ payload.projectSlug = args.project;
224
+ if (args.description != null)
225
+ payload.description = args.description;
226
+ if (args.tags != null)
227
+ payload.tags = args.tags;
228
+ if (args.bindrs != null)
229
+ payload.bindrs = args.bindrs;
230
+ if (args.writeProtected)
231
+ payload.writeProtected = true;
232
+ if (args.metadata != null)
233
+ payload.metadata = args.metadata;
234
+ const result = await this.postWith409Retry(`namespaces/${ns}/nodes`, { json: payload });
235
+ if (result?.experiment?.id && result?.node?.id) {
236
+ this.idCache.set(`exp_node:${asId(result.experiment.id)}`, asId(result.node.id));
237
+ }
238
+ return result;
239
+ }
240
+ async createLogEntries(experimentId, logs) {
241
+ return this.requestJson(`experiments/${experimentId}/logs`, { method: "POST", json: { logs } });
242
+ }
243
+ async setParameters(experimentId, data) {
244
+ return this.requestJson(`experiments/${experimentId}/parameters`, {
245
+ method: "POST",
246
+ json: { data },
247
+ });
248
+ }
249
+ async getParameters(experimentId) {
250
+ const result = await this.requestJson(`experiments/${experimentId}/parameters`);
251
+ return result.data ?? {};
252
+ }
253
+ async queryLogs(experimentId, opts = {}) {
254
+ return this.requestJson(`experiments/${experimentId}/logs`, {
255
+ params: {
256
+ limit: opts.limit,
257
+ offset: opts.offset,
258
+ orderBy: opts.orderBy,
259
+ order: opts.order,
260
+ level: opts.level?.join(","),
261
+ startTime: opts.startTime,
262
+ endTime: opts.endTime,
263
+ search: opts.search,
264
+ },
265
+ });
266
+ }
267
+ // ── metrics ────────────────────────────────────────────────────────────────
268
+ async appendBatchToMetric(experimentId, metricName, dataPoints) {
269
+ return this.requestJson(`experiments/${experimentId}/metrics/${encodeURIComponent(metricName)}/append-batch`, { method: "POST", json: { dataPoints } });
270
+ }
271
+ async getMetricData(experimentId, metricName, opts = {}) {
272
+ return this.requestJson(`experiments/${experimentId}/metrics/${encodeURIComponent(metricName)}/data`, {
273
+ params: {
274
+ startIndex: opts.startIndex,
275
+ limit: opts.limit,
276
+ bufferOnly: opts.bufferOnly ? "true" : undefined,
277
+ },
278
+ });
279
+ }
280
+ async getMetricStats(experimentId, metricName) {
281
+ return this.requestJson(`experiments/${experimentId}/metrics/${encodeURIComponent(metricName)}/stats`);
282
+ }
283
+ async downloadMetricChunk(experimentId, metricName, chunkNumber) {
284
+ return this.requestJson(`experiments/${experimentId}/metrics/${encodeURIComponent(metricName)}/chunks/${chunkNumber}`);
285
+ }
286
+ async listMetrics(experimentId) {
287
+ const result = await this.requestJson(`experiments/${experimentId}/metrics`);
288
+ return result.metrics ?? [];
289
+ }
290
+ // ── files ──────────────────────────────────────────────────────────────────
291
+ /**
292
+ * Upload one file, creating any folders its prefix names.
293
+ *
294
+ * The file is streamed off disk rather than read whole: the Python CLI did
295
+ * `f.read()` into memory, which puts a multi-GB checkpoint in the heap before
296
+ * a single byte leaves the machine.
297
+ */
298
+ async uploadFile(args) {
299
+ const ns = await this.namespace();
300
+ let projectId = args.projectId ?? null;
301
+ let parentId = args.parentId ?? "ROOT";
302
+ if (!projectId) {
303
+ const result = await this.graphqlQuery(`query GetExperimentProject($experimentId: ID!) {
304
+ experimentById(id: $experimentId) { projectId }
305
+ }`, { experimentId: args.experimentId });
306
+ projectId = asId(result?.experimentById?.projectId) ?? null;
307
+ if (!projectId) {
308
+ throw new ConfigurationError(`Could not resolve project ID for experiment ${args.experimentId}`);
309
+ }
310
+ }
311
+ // Files belong under the experiment node, not the project root.
312
+ let experimentNodeId = this.idCache.get(`exp_node:${args.experimentId}`);
313
+ if (!experimentNodeId) {
314
+ experimentNodeId = await this.getExperimentNodeId(args.experimentId);
315
+ }
316
+ if (experimentNodeId)
317
+ parentId = experimentNodeId;
318
+ let prefix = args.prefix ?? "";
319
+ if (prefix) {
320
+ for (const folderName of prefix.split("/")) {
321
+ if (!folderName)
322
+ continue;
323
+ const folder = await this.postWith409Retry(`namespaces/${ns}/nodes`, {
324
+ json: {
325
+ type: "FOLDER",
326
+ projectId,
327
+ experimentId: args.experimentId,
328
+ parentId,
329
+ name: folderName,
330
+ },
331
+ });
332
+ parentId = asId(folder?.node?.id);
333
+ }
334
+ }
335
+ const handle = await open(args.filePath, "r");
336
+ let result;
337
+ try {
338
+ const { size } = await handle.stat();
339
+ const form = new FormData();
340
+ // A Blob over the open handle keeps the bytes out of the JS heap.
341
+ const stream = handle.readableWebStream();
342
+ const blob = new Blob([await new Response(stream).arrayBuffer()], {
343
+ type: args.contentType || "application/octet-stream",
344
+ });
345
+ form.append("file", blob, args.filename);
346
+ form.append("type", "FILE");
347
+ form.append("projectId", String(projectId));
348
+ form.append("experimentId", String(args.experimentId));
349
+ form.append("parentId", String(parentId));
350
+ form.append("name", args.filename);
351
+ form.append("checksum", args.checksum);
352
+ if (args.description)
353
+ form.append("description", args.description);
354
+ if (args.tags?.length)
355
+ form.append("tags", args.tags.join(","));
356
+ if (args.metadata)
357
+ form.append("metadata", JSON.stringify(args.metadata));
358
+ result = await this.postWith409Retry(`namespaces/${ns}/nodes`, {
359
+ form,
360
+ timeoutMs: Math.max(30_000, Math.ceil(size / 1024) * 10),
361
+ });
362
+ }
363
+ finally {
364
+ await handle.close();
365
+ }
366
+ const node = result?.node ?? {};
367
+ const physical = result?.physicalFile ?? {};
368
+ return {
369
+ id: asId(node.id),
370
+ experimentId: asId(node.experimentId) ?? args.experimentId,
371
+ path: prefix,
372
+ filename: args.filename,
373
+ description: node.description,
374
+ tags: node.tags ?? [],
375
+ contentType: physical.contentType,
376
+ sizeBytes: physical.sizeBytes != null ? Number(physical.sizeBytes) : undefined,
377
+ checksum: physical.checksum,
378
+ metadata: node.metadata,
379
+ uploadedAt: node.createdAt,
380
+ updatedAt: node.updatedAt,
381
+ deletedAt: node.deletedAt,
382
+ };
383
+ }
384
+ async getExperimentNodeId(experimentId) {
385
+ const cacheKey = `exp_node:${experimentId}`;
386
+ const cached = this.idCache.get(cacheKey);
387
+ if (cached)
388
+ return cached;
389
+ const result = await this.graphqlQuery(`query GetExperimentNode($experimentId: ID!) {
390
+ experimentNode(experimentId: $experimentId) { id }
391
+ }`, { experimentId });
392
+ const id = asId(result?.experimentNode?.id);
393
+ if (!id)
394
+ throw new ConfigurationError(`No node found for experiment ID '${experimentId}'`);
395
+ this.idCache.set(cacheKey, id);
396
+ return id;
397
+ }
398
+ async getFile(fileId) {
399
+ return this.requestJson(`nodes/${fileId}`);
400
+ }
401
+ async listFiles(experimentId, limit = 500, offset = 0) {
402
+ const result = await this.graphqlQuery(`query ListExperimentFilesPaginated($experimentId: ID!, $limit: Int!, $offset: Int!) {
403
+ experimentById(id: $experimentId) {
404
+ filesPaginated(limit: $limit, offset: $offset) {
405
+ files {
406
+ id name description tags metadata createdAt pPath
407
+ physicalFile { id filename contentType sizeBytes checksum s3Url }
408
+ }
409
+ totalCount
410
+ hasMore
411
+ }
412
+ }
413
+ }`, { experimentId, limit, offset });
414
+ const page = result?.experimentById?.filesPaginated ?? {};
415
+ return { files: page.files ?? [], totalCount: page.totalCount ?? 0, hasMore: page.hasMore ?? false };
416
+ }
417
+ async searchFiles(pattern, experimentId, limit = 10, offset = 0) {
418
+ const result = await this.requestJson("search/files", {
419
+ params: { pattern, limit, offset, experimentId },
420
+ });
421
+ return result.files ?? [];
422
+ }
423
+ async deleteFile(fileId) {
424
+ return this.requestJson(`nodes/${fileId}`, { method: "DELETE" });
425
+ }
426
+ /**
427
+ * Stream a file to disk.
428
+ *
429
+ * Checksum verification belongs to the caller: `download` hashes into a
430
+ * scratch directory and only copies a file into the tree once it matches.
431
+ */
432
+ async downloadFileStreaming(fileId, destPath) {
433
+ const res = await this.request(`nodes/${fileId}/download`, { timeoutMs: 600_000, raw: true });
434
+ if (!res.ok)
435
+ throw new HttpError(res.status, `nodes/${fileId}/download`, await res.text());
436
+ if (!res.body)
437
+ throw new NetworkError(`Empty response body downloading file ${fileId}`);
438
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
439
+ await stat(destPath);
440
+ return destPath;
441
+ }
442
+ // ── GraphQL reads ──────────────────────────────────────────────────────────
443
+ async listProjectsGraphql(namespaceSlug, limit = 50, offset = 0) {
444
+ const result = await this.graphqlQuery(`query ProjectsPaginated($namespaceSlug: String, $limit: Int!, $offset: Int!) {
445
+ projectsPaginated(namespaceSlug: $namespaceSlug, limit: $limit, offset: $offset) {
446
+ projects { id name slug description tags experimentCount }
447
+ totalCount
448
+ hasMore
449
+ }
450
+ }`, { namespaceSlug: namespaceSlug ?? null, limit, offset });
451
+ const page = result?.projectsPaginated ?? {};
452
+ return { projects: page.projects ?? [], totalCount: page.totalCount ?? 0 };
453
+ }
454
+ async listExperimentsGraphql(projectSlug, opts = {}) {
455
+ const variables = {
456
+ namespaceSlug: opts.namespaceSlug ?? null,
457
+ projectSlug,
458
+ limit: opts.limit ?? 50,
459
+ offset: opts.offset ?? 0,
460
+ };
461
+ if (opts.status != null)
462
+ variables.status = opts.status;
463
+ const result = await this.graphqlQuery(`query ExperimentsPaginated($namespaceSlug: String, $projectSlug: String!, $status: ExperimentStatus, $limit: Int!, $offset: Int!) {
464
+ experimentsPaginated(namespaceSlug: $namespaceSlug, projectSlug: $projectSlug, status: $status, limit: $limit, offset: $offset) {
465
+ experiments {
466
+ id name description tags status startedAt endedAt metadata
467
+ project { slug namespace { slug } }
468
+ logMetadata { totalLogs }
469
+ metrics { name }
470
+ files { id }
471
+ trackCount
472
+ displayPath
473
+ }
474
+ totalCount
475
+ hasMore
476
+ }
477
+ }`, variables);
478
+ const page = result?.experimentsPaginated ?? {};
479
+ return { experiments: page.experiments ?? [], totalCount: page.totalCount ?? 0 };
480
+ }
481
+ async getExperimentGraphql(projectSlug, experimentName, namespaceSlug) {
482
+ const ns = namespaceSlug ?? (await this.namespace());
483
+ const result = await this.graphqlQuery(`query Experiment($namespaceSlug: String, $projectSlug: String!, $experimentName: String!) {
484
+ experiment(namespaceSlug: $namespaceSlug, projectSlug: $projectSlug, experimentName: $experimentName) {
485
+ id name description tags status metadata
486
+ project { slug namespace { slug } }
487
+ logMetadata { totalLogs }
488
+ metrics { name metricMetadata { totalDataPoints } }
489
+ files {
490
+ id name pPath description tags metadata
491
+ physicalFile { filename contentType sizeBytes checksum s3Url }
492
+ }
493
+ parameters { id data }
494
+ }
495
+ }`, { namespaceSlug: ns, projectSlug, experimentName });
496
+ return result?.experiment ?? null;
497
+ }
498
+ async searchExperimentsGraphql(pattern, limit = 50, offset = 0) {
499
+ const result = await this.graphqlQuery(`query SearchExperimentsPaginated($pattern: String!, $limit: Int!, $offset: Int!) {
500
+ searchExperimentsPaginated(pattern: $pattern, limit: $limit, offset: $offset) {
501
+ experiments {
502
+ id name description tags status startedAt endedAt metadata
503
+ project { id slug name namespace { id slug } }
504
+ logMetadata { totalLogs }
505
+ metrics { name metricMetadata { totalDataPoints } }
506
+ files { id name }
507
+ trackCount
508
+ displayPath
509
+ }
510
+ totalCount
511
+ hasMore
512
+ }
513
+ }`, { pattern, limit, offset });
514
+ const page = result?.searchExperimentsPaginated ?? {};
515
+ return { experiments: page.experiments ?? [], totalCount: page.totalCount ?? 0 };
516
+ }
517
+ /** Walk the project node tree to find an experiment nested under folders. */
518
+ async getExperimentByPathGraphql(projectSlug, experimentPath, namespaceSlug) {
519
+ const ns = namespaceSlug ?? (await this.namespace());
520
+ const result = await this.graphqlQuery(`query GetProjectHierarchy($namespaceSlug: String!, $projectSlug: String!) {
521
+ project(namespaceSlug: $namespaceSlug, projectSlug: $projectSlug) {
522
+ nodes(parentId: null, maxDepth: 20) {
523
+ ...NodeFields
524
+ children { ...NodeFields
525
+ children { ...NodeFields
526
+ children { ...NodeFields
527
+ children { ...NodeFields
528
+ children { ...NodeFields
529
+ children { ...NodeFields
530
+ children { ...NodeFields
531
+ children { ...NodeFields
532
+ children { ...NodeFields } } } } } } } } }
533
+ }
534
+ }
535
+ }
536
+ fragment NodeFields on Node { id name type pPath experimentId }`, { namespaceSlug: ns, projectSlug });
537
+ const parts = experimentPath.replace(/^\/+|\/+$/g, "").split("/");
538
+ const find = async (nodes, remaining) => {
539
+ if (remaining.length === 0)
540
+ return null;
541
+ const [head, ...rest] = remaining;
542
+ for (const node of nodes ?? []) {
543
+ if (node?.name !== head)
544
+ continue;
545
+ if (rest.length === 0 && node.type === "EXPERIMENT") {
546
+ const experimentId = asId(node.experimentId);
547
+ if (!experimentId)
548
+ return null;
549
+ const exp = await this.graphqlQuery(`query GetExperiment($id: ID!) {
550
+ experimentById(id: $id) {
551
+ id name description tags status metadata
552
+ project { slug namespace { slug } }
553
+ }
554
+ }`, { id: experimentId });
555
+ return exp?.experimentById ?? null;
556
+ }
557
+ if (rest.length > 0 && node.children) {
558
+ const found = await find(node.children, rest);
559
+ if (found)
560
+ return found;
561
+ }
562
+ }
563
+ return null;
564
+ };
565
+ return find(result?.project?.nodes ?? [], parts);
566
+ }
567
+ // ── tracks ─────────────────────────────────────────────────────────────────
568
+ async listTracks(experimentId, topicFilter) {
569
+ const result = await this.requestJson(`experiments/${experimentId}/tracks`, {
570
+ params: { topic: topicFilter },
571
+ });
572
+ return result.tracks ?? [];
573
+ }
574
+ async appendBatchToTrack(experimentId, topic, entries) {
575
+ // `append_batch`, with an underscore: the track routes spell it that way
576
+ // (`/experiments/:id/tracks/:topic/append_batch` in the server, and the
577
+ // same in the Python client), while the *metric* route next door is
578
+ // `append-batch`. The hyphen here 404'd every track upload.
579
+ return this.requestJson(`experiments/${experimentId}/tracks/${encodeURIComponent(topic)}/append_batch`, { method: "POST", json: { entries }, timeoutMs: 120_000 });
580
+ }
581
+ /** Track export. jsonl/parquet/mcap come back as bytes; json as a parsed object. */
582
+ async getTrackData(experimentId, topic, format) {
583
+ const res = await this.request(`experiments/${experimentId}/tracks/${encodeURIComponent(topic)}/data`, { params: { format }, timeoutMs: 600_000 });
584
+ if (format === "json")
585
+ return parseJson(await res.text());
586
+ return Buffer.from(await res.arrayBuffer());
587
+ }
588
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `ml-dash api` — raw GraphQL against the server.
3
+ *
4
+ * Two conveniences from the Python CLI are load-bearing and reproduced exactly:
5
+ * a bare selection set is wrapped in `{ … }` (or `mutation { … }`), and single
6
+ * quotes are rewritten to double quotes so a query survives shell quoting.
7
+ */
8
+ import { makeClient, notAuthenticatedMessage, resolveContext } from "../cli/context.js";
9
+ import { red } from "../util/ansi.js";
10
+ export const spec = {
11
+ name: "api",
12
+ help: "Send GraphQL queries to ml-dash server",
13
+ description: `Send GraphQL queries to the ml-dash server.
14
+
15
+ Examples:
16
+ ml-dash api --query "me { username name email }"
17
+ ml-dash api --query "user(title: 'hello') { id title }"
18
+ ml-dash api --query "me { username }" --jq ".me.username"
19
+ ml-dash api --mutation "updateUser(username: 'newname') { username }"
20
+
21
+ Notes:
22
+ - Single quotes are auto-converted to double quotes for GraphQL
23
+ - Use --jq for dot-path extraction (built-in, no deps)`,
24
+ options: [
25
+ { flags: ["--query", "-q"], dest: "query", metavar: "QUERY", help: "GraphQL query string" },
26
+ { flags: ["--mutation", "-m"], dest: "mutation", metavar: "MUTATION", help: "GraphQL mutation string" },
27
+ { flags: ["--jq"], dest: "jq", metavar: "PATH", help: "Extract value using dot-path (e.g., .me.username)" },
28
+ { flags: ["--dash-url", "--api-url"], dest: "dash_url", metavar: "URL", help: "ML-Dash server URL (default: https://api.dash.ml)" },
29
+ ],
30
+ mutuallyExclusive: [{ dests: ["query", "mutation"], required: true }],
31
+ };
32
+ /** GraphQL wants double quotes; shells want single. Trade one for the other. */
33
+ export const fixQuotes = (q) => q.replace(/'/g, '"');
34
+ export function buildQuery(query, isMutation) {
35
+ const q = fixQuotes(query.trim());
36
+ if (q.startsWith("{") || q.startsWith("mutation") || q.startsWith("query"))
37
+ return q;
38
+ return isMutation ? `mutation { ${q} }` : `{ ${q} }`;
39
+ }
40
+ /** Dot-path lookup. A numeric segment indexes into an array, as in the Python CLI. */
41
+ export function extractPath(data, path) {
42
+ let cur = data;
43
+ for (const key of path.replace(/^\.+/, "").split(".")) {
44
+ if (!key)
45
+ continue;
46
+ if (Array.isArray(cur)) {
47
+ const idx = Number(key);
48
+ if (!Number.isInteger(idx))
49
+ throw new Error(`Cannot index array with '${key}'`);
50
+ cur = cur[idx];
51
+ }
52
+ else if (cur !== null && typeof cur === "object") {
53
+ if (!(key in cur))
54
+ throw new Error(`Key '${key}' not found`);
55
+ cur = cur[key];
56
+ }
57
+ else {
58
+ throw new Error(`Cannot access '${key}' on ${cur === null ? "null" : typeof cur}`);
59
+ }
60
+ }
61
+ return cur;
62
+ }
63
+ export async function run(args) {
64
+ const ctx = resolveContext(args);
65
+ if (!ctx.apiKey) {
66
+ console.error(`${red("Error:")} ${notAuthenticatedMessage(ctx)}`);
67
+ return 1;
68
+ }
69
+ try {
70
+ const isMutation = typeof args.mutation === "string";
71
+ const query = buildQuery(String(isMutation ? args.mutation : args.query), isMutation);
72
+ let result = await makeClient(ctx).graphqlQuery(query);
73
+ if (typeof args.jq === "string") {
74
+ try {
75
+ result = extractPath(result, args.jq);
76
+ }
77
+ catch (e) {
78
+ console.error(red(`Error extracting path '${args.jq}': ${e.message}`));
79
+ return 1;
80
+ }
81
+ }
82
+ console.log(JSON.stringify(result, null, 2));
83
+ return 0;
84
+ }
85
+ catch (e) {
86
+ console.error(red(`Error: ${e.message}`));
87
+ return 1;
88
+ }
89
+ }