@assethub/cli 0.1.6 → 0.1.8

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.
Files changed (47) hide show
  1. package/README.md +289 -5
  2. package/dist/canvas.d.ts +27 -0
  3. package/dist/canvas.d.ts.map +1 -0
  4. package/dist/canvas.js +82 -0
  5. package/dist/canvasAssetImport.d.ts +45 -0
  6. package/dist/canvasAssetImport.d.ts.map +1 -0
  7. package/dist/canvasAssetImport.js +154 -0
  8. package/dist/canvasComparison.d.ts +21 -0
  9. package/dist/canvasComparison.d.ts.map +1 -0
  10. package/dist/canvasComparison.js +58 -0
  11. package/dist/execution.d.ts +175 -0
  12. package/dist/execution.d.ts.map +1 -0
  13. package/dist/execution.js +234 -0
  14. package/dist/index.d.ts +94 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1567 -189
  17. package/dist/projectSources.d.ts +65 -0
  18. package/dist/projectSources.d.ts.map +1 -0
  19. package/dist/projectSources.js +369 -0
  20. package/dist/projectSourcesWorkbook.d.ts +3 -0
  21. package/dist/projectSourcesWorkbook.d.ts.map +1 -0
  22. package/dist/projectSourcesWorkbook.js +143 -0
  23. package/dist/runsUpload/artifactGraphModel.d.ts +48 -0
  24. package/dist/runsUpload/artifactGraphModel.d.ts.map +1 -0
  25. package/dist/runsUpload/artifactGraphModel.js +127 -0
  26. package/dist/runsUpload/blobRefs.d.ts +13 -0
  27. package/dist/runsUpload/blobRefs.d.ts.map +1 -0
  28. package/dist/runsUpload/blobRefs.js +73 -0
  29. package/dist/runsUpload/buildRunUpload.d.ts +63 -0
  30. package/dist/runsUpload/buildRunUpload.d.ts.map +1 -0
  31. package/dist/runsUpload/buildRunUpload.js +428 -0
  32. package/dist/runsUpload/canonicalJson.d.ts +5 -0
  33. package/dist/runsUpload/canonicalJson.d.ts.map +1 -0
  34. package/dist/runsUpload/canonicalJson.js +108 -0
  35. package/dist/runsUpload/controlEndpoints.d.ts +63 -0
  36. package/dist/runsUpload/controlEndpoints.d.ts.map +1 -0
  37. package/dist/runsUpload/controlEndpoints.js +64 -0
  38. package/dist/runsUpload/graphFolder.d.ts +25 -0
  39. package/dist/runsUpload/graphFolder.d.ts.map +1 -0
  40. package/dist/runsUpload/graphFolder.js +147 -0
  41. package/dist/runsUpload/runUploadError.d.ts +23 -0
  42. package/dist/runsUpload/runUploadError.d.ts.map +1 -0
  43. package/dist/runsUpload/runUploadError.js +24 -0
  44. package/dist/runsUpload/uploadRun.d.ts +43 -0
  45. package/dist/runsUpload/uploadRun.d.ts.map +1 -0
  46. package/dist/runsUpload/uploadRun.js +217 -0
  47. package/package.json +11 -5
@@ -0,0 +1,428 @@
1
+ // Turn a local `ag.graph-folder.v1` run folder into an `ag.registry-push.v1`
2
+ // envelope, and refuse to build one the server would reject.
3
+ //
4
+ // Port of assethub-ml research/artifact-graph/artifact-graph-py/
5
+ // artifact_graph/registry/validation.py `snapshot_from_graph_folder` ->
6
+ // `make_registry_snapshot` -> `_reference_only_manifest` / `_reference_only_blob`,
7
+ // plus `service.py::make_push`.
8
+ //
9
+ // Two things this file is careful about:
10
+ //
11
+ // - The wire form is *reference-only*. A folder's manifest blob table carries
12
+ // `file: "blobs/…"` so a reader can find the bytes on disk; the registry
13
+ // rejects any snapshot whose blob entries carry a path, URL or inline bytes
14
+ // (`INLINE_BLOB_FIELDS` in registry-worker/src/validation.js). The paths are
15
+ // kept out-of-band, on the plan, for the uploader to stream from.
16
+ //
17
+ // - It validates locally what the server validates remotely. The expensive
18
+ // failure mode is a corrupt folder discovered *after* N blobs have already
19
+ // been uploaded, so every check that can be made from local bytes is made
20
+ // before the first request. `--dry-run` is exactly this function plus a
21
+ // report.
22
+ import { stat } from 'node:fs/promises';
23
+ import { canonicalGraphHash } from './artifactGraphModel.js';
24
+ import { reachableBlobRefs } from './blobRefs.js';
25
+ import { canonicalJson, isPlainObject, utf8ByteLength } from './canonicalJson.js';
26
+ import { CONTROL_MAX_BLOB_BYTES, CONTROL_MAX_PUSH_BYTES, REGISTRY_MAX_GRAPH_ARRAY_BYTES, REGISTRY_MAX_MANIFEST_BYTES, } from './controlEndpoints.js';
27
+ import { deriveFlows, deriveStates } from './graphFolder.js';
28
+ import { RunUploadError } from './runUploadError.js';
29
+ export const PUSH_SCHEMA_VERSION = 'ag.registry-push.v1';
30
+ export const SNAPSHOT_SCHEMA_VERSION = 'ag.registry-snapshot.v1';
31
+ export const GRAPH_FOLDER_SCHEMA_VERSION = 'ag.graph-folder.v1';
32
+ export const SHARED_BLOB_STORE = 'shared-content-addressed';
33
+ export const DEFAULT_GENERATOR = 'assethub-cli:runs.upload';
34
+ const GRAPH_ID_RE = /^(?:graph_[A-Za-z0-9][A-Za-z0-9._:-]{0,121}|import_[A-Za-z0-9][A-Za-z0-9._:-]{0,120})$/;
35
+ const GRAPH_HASH_RE = /^sha256:[0-9a-f]{64}$/;
36
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
37
+ const BLOB_REF_FIELDS = ['key', 'mime', 'size', 'sha256', 'name', 'artifactId', 'metadata'];
38
+ const MANIFEST_FIELDS = [
39
+ 'schemaVersion',
40
+ 'createdAt',
41
+ 'generator',
42
+ 'selection',
43
+ 'graph',
44
+ 'blobStore',
45
+ 'flows',
46
+ 'states',
47
+ 'blobs',
48
+ 'missingBlobs',
49
+ 'source',
50
+ 'run',
51
+ ];
52
+ // ISO-8601 date-time with an explicit offset, matching the worker's own
53
+ // `dateTime()` accept set. A plain date, or a time without a zone, is rejected.
54
+ const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?(?:[Zz]|[+-]\d{2}:\d{2}(?::\d{2})?)$/;
55
+ export const buildRunUploadPlan = async ({ folder, graphId: graphIdInput, streamId: streamIdInput, rev: revInput, description = '', tags = [], now = new Date(), }) => {
56
+ const warnings = [];
57
+ const manifest = folder.manifest;
58
+ const source = isPlainObject(manifest.source) ? manifest.source : null;
59
+ const graphId = validateGraphId(graphIdInput ?? defaultGraphId(folder.name), graphIdInput == null ? folder.name : undefined);
60
+ const streamId = validateStreamId(streamIdInput ?? defaultStreamId(source, graphId));
61
+ const rev = validateRev(revInput ?? asNonNegativeInteger(source?.rev) ?? 0);
62
+ const graph = folder.graph;
63
+ const graphHash = canonicalGraphHash(graph);
64
+ // --- manifest, rewritten reference-only -----------------------------------
65
+ rejectUnknownFields(manifest, MANIFEST_FIELDS, 'manifest.json');
66
+ const missingBlobs = manifest.missingBlobs;
67
+ if (Array.isArray(missingBlobs) && missingBlobs.length > 0) {
68
+ throw new RunUploadError('folder_incomplete', `This run folder is incomplete: manifest.missingBlobs lists ${missingBlobs.length} blob(s) whose bytes were never exported, ` +
69
+ 'and Production Control only accepts a run whose every referenced blob is present. ' +
70
+ 'Re-export the run with its blobs, then upload that folder.', { details: { blobKeys: missingBlobs.slice(0, 20) } });
71
+ }
72
+ const createdAt = manifestCreatedAt(manifest, now);
73
+ const generator = manifestGenerator(manifest);
74
+ if (isPlainObject(manifest.selection) && manifest.selection.kind !== 'whole-graph') {
75
+ // The reference implementation overwrites this unconditionally; matching it
76
+ // keeps the bytes identical, but a relabelled subgraph is worth saying out
77
+ // loud rather than shipping silently.
78
+ warnings.push(`manifest.selection.kind was ${JSON.stringify(manifest.selection.kind)}; ` +
79
+ 'the snapshot declares selection {"kind":"whole-graph"}, so this upload will present the folder as a whole graph.');
80
+ }
81
+ // --- blobs ----------------------------------------------------------------
82
+ const declaredRaw = Array.isArray(manifest.blobs) ? manifest.blobs : [];
83
+ if (!Array.isArray(manifest.blobs)) {
84
+ throw new RunUploadError('manifest_blobs_invalid', 'manifest.json must carry a `blobs` array (the folder blob table).');
85
+ }
86
+ const declared = declaredRaw.map((entry, index) => {
87
+ if (!isPlainObject(entry)) {
88
+ throw new RunUploadError('manifest_blobs_invalid', `manifest.blobs[${index}] must be an object.`);
89
+ }
90
+ return referenceOnlyBlob(entry, index);
91
+ });
92
+ const seen = new Set();
93
+ for (const ref of declared) {
94
+ if (seen.has(ref.key)) {
95
+ throw new RunUploadError('manifest_blobs_invalid', `manifest.blobs declares the blob key ${ref.key} twice.`);
96
+ }
97
+ seen.add(ref.key);
98
+ }
99
+ declared.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
100
+ const reachable = reachableBlobRefs(graph);
101
+ assertBlobSetsAgree(reachable, declared);
102
+ assertBlobMetadataAgrees(reachable, declared);
103
+ const nextManifest = {
104
+ ...manifest,
105
+ schemaVersion: GRAPH_FOLDER_SCHEMA_VERSION,
106
+ createdAt,
107
+ generator,
108
+ selection: { kind: 'whole-graph' },
109
+ graph: manifest.graph,
110
+ flows: deriveFlows(graph),
111
+ states: deriveStates(graph),
112
+ blobs: declared,
113
+ missingBlobs: [],
114
+ };
115
+ if ('blobStore' in nextManifest)
116
+ nextManifest.blobStore = SHARED_BLOB_STORE;
117
+ // --- the folder must still describe itself --------------------------------
118
+ assertManifestGraphMatches(manifest.graph, graph, graphHash, folder.path);
119
+ // --- sizes ----------------------------------------------------------------
120
+ const manifestBytes = utf8ByteLength(canonicalJson(nextManifest));
121
+ const nodesBytes = utf8ByteLength(canonicalJson(graph.nodes));
122
+ const edgesBytes = utf8ByteLength(canonicalJson(graph.edges));
123
+ assertUnderLimit('manifest', manifestBytes, REGISTRY_MAX_MANIFEST_BYTES);
124
+ assertUnderLimit('nodes', nodesBytes, REGISTRY_MAX_GRAPH_ARRAY_BYTES);
125
+ assertUnderLimit('edges', edgesBytes, REGISTRY_MAX_GRAPH_ARRAY_BYTES);
126
+ const snapshot = {
127
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
128
+ graphId,
129
+ streamId,
130
+ rev,
131
+ graphHash,
132
+ blobStore: SHARED_BLOB_STORE,
133
+ manifest: nextManifest,
134
+ nodes: graph.nodes,
135
+ edges: graph.edges,
136
+ blobs: declared,
137
+ };
138
+ const push = {
139
+ schemaVersion: PUSH_SCHEMA_VERSION,
140
+ graphId,
141
+ streamId,
142
+ rev,
143
+ graphHash,
144
+ snapshot,
145
+ };
146
+ const pushBody = JSON.stringify(push);
147
+ const pushBytes = utf8ByteLength(pushBody);
148
+ if (pushBytes > CONTROL_MAX_PUSH_BYTES) {
149
+ throw new RunUploadError('push_too_large', `This run's snapshot is ${formatBytes(pushBytes)}, over the ${formatBytes(CONTROL_MAX_PUSH_BYTES)} Production Control accepts in one request ` +
150
+ `(${graph.nodes.length} nodes, ${graph.edges.length} edges). Nothing was sent. ` +
151
+ 'Export a narrower selection of the run, or ask for the upload limit to be raised before retrying.', { details: { pushBytes, limitBytes: CONTROL_MAX_PUSH_BYTES } });
152
+ }
153
+ // --- the bytes each blob needs, checked but not read ----------------------
154
+ const blobs = await planBlobs(declared, folder);
155
+ return {
156
+ graphId,
157
+ streamId,
158
+ rev,
159
+ graphHash,
160
+ push,
161
+ pushBody,
162
+ blobs,
163
+ counts: {
164
+ nodes: graph.nodes.length,
165
+ edges: graph.edges.length,
166
+ blobs: blobs.length,
167
+ blobBytes: blobs.reduce((total, blob) => total + blob.size, 0),
168
+ },
169
+ sizes: { manifestBytes, nodesBytes, edgesBytes, pushBytes },
170
+ registration: {
171
+ graphId,
172
+ description,
173
+ tags: normalizeTags(tags),
174
+ metadata: registrationMetadata(folder, createdAt, generator),
175
+ },
176
+ warnings,
177
+ };
178
+ };
179
+ // ---------------------------------------------------------------------------
180
+ // identity
181
+ // ---------------------------------------------------------------------------
182
+ /** `import_` rather than `graph_`: a local run folder is an import, not a graph
183
+ * this deployment owns the authority for. */
184
+ export const defaultGraphId = (folderName) => {
185
+ const slug = folderName
186
+ .replace(/[^A-Za-z0-9._:-]+/g, '-')
187
+ .replace(/^[^A-Za-z0-9]+/, '')
188
+ .slice(0, 121)
189
+ .replace(/-+$/, '');
190
+ if (!slug) {
191
+ throw new RunUploadError('graph_id_undeterminable', `Could not derive a graph id from the folder name ${JSON.stringify(folderName)}. Pass --graph-id import_<name> explicitly.`);
192
+ }
193
+ return `import_${slug}`;
194
+ };
195
+ const defaultStreamId = (source, graphId) => {
196
+ const authorityId = typeof source?.authorityId === 'string' ? source.authorityId.trim() : '';
197
+ return authorityId || `stream_cli_${graphId}`;
198
+ };
199
+ const validateGraphId = (value, derivedFrom) => {
200
+ if (!GRAPH_ID_RE.test(value)) {
201
+ throw new RunUploadError('invalid_graph_id', derivedFrom == null
202
+ ? `--graph-id must start with graph_ or import_ and then use only letters, digits, '.', '_', ':' or '-' (got ${JSON.stringify(value)}).`
203
+ : `The graph id derived from the folder name ${JSON.stringify(derivedFrom)} is not valid (${JSON.stringify(value)}). ` +
204
+ "It must start with graph_ or import_ and then use only letters, digits, '.', '_', ':' or '-'. Pass --graph-id to set it explicitly.");
205
+ }
206
+ return value;
207
+ };
208
+ const validateStreamId = (value) => {
209
+ if (typeof value !== 'string' || !value.trim() || value.length > 256) {
210
+ throw new RunUploadError('invalid_stream_id', '--stream-id must be a non-empty string of at most 256 characters.');
211
+ }
212
+ return value;
213
+ };
214
+ const validateRev = (value) => {
215
+ if (!Number.isInteger(value) || value < 0) {
216
+ throw new RunUploadError('invalid_rev', '--rev must be a non-negative integer.');
217
+ }
218
+ return value;
219
+ };
220
+ // ---------------------------------------------------------------------------
221
+ // manifest
222
+ // ---------------------------------------------------------------------------
223
+ const manifestCreatedAt = (manifest, now) => {
224
+ const raw = manifest.createdAt;
225
+ if (raw === undefined || raw === null || raw === '')
226
+ return isoNoMs(now);
227
+ if (typeof raw !== 'string' || !ISO_DATE_TIME_RE.test(raw)) {
228
+ throw new RunUploadError('manifest_created_at_invalid', `manifest.createdAt must be an ISO-8601 date-time including a timezone (got ${JSON.stringify(raw)}).`);
229
+ }
230
+ return raw;
231
+ };
232
+ const manifestGenerator = (manifest) => {
233
+ const raw = manifest.generator;
234
+ if (raw === undefined || raw === null || raw === '')
235
+ return DEFAULT_GENERATOR;
236
+ if (typeof raw !== 'string' || !raw.trim() || raw.length > 256) {
237
+ throw new RunUploadError('manifest_generator_invalid', 'manifest.generator must be a non-empty string of at most 256 characters.');
238
+ }
239
+ return raw;
240
+ };
241
+ const assertManifestGraphMatches = (value, graph, graphHash, folderPath) => {
242
+ if (!isPlainObject(value)) {
243
+ throw new RunUploadError('manifest_graph_invalid', 'manifest.graph must be an object with nodeCount, edgeCount and graphHash.');
244
+ }
245
+ const keys = Object.keys(value).sort().join(',');
246
+ if (keys !== 'edgeCount,graphHash,nodeCount') {
247
+ throw new RunUploadError('manifest_graph_invalid', 'manifest.graph must contain exactly nodeCount, edgeCount and graphHash.');
248
+ }
249
+ const actual = {
250
+ nodeCount: graph.nodes.length,
251
+ edgeCount: graph.edges.length,
252
+ graphHash,
253
+ };
254
+ if (value.nodeCount !== actual.nodeCount ||
255
+ value.edgeCount !== actual.edgeCount ||
256
+ value.graphHash !== actual.graphHash) {
257
+ throw new RunUploadError('folder_corrupt', `This run folder no longer describes itself: its manifest graph counts or hash do not match its own nodes.jsonl / edges.jsonl. ` +
258
+ `manifest says nodeCount=${String(value.nodeCount)} edgeCount=${String(value.edgeCount)} graphHash=${String(value.graphHash)}; ` +
259
+ `the files hash to nodeCount=${actual.nodeCount} edgeCount=${actual.edgeCount} graphHash=${actual.graphHash}. ` +
260
+ `Nothing was sent. Re-export ${folderPath} instead of editing it by hand.`, { details: { expected: actual } });
261
+ }
262
+ };
263
+ // ---------------------------------------------------------------------------
264
+ // blobs
265
+ // ---------------------------------------------------------------------------
266
+ /** Port of `_reference_only_blob`: keeps only the fields the wire form allows,
267
+ * which is also what drops the folder-only `file` path. */
268
+ const referenceOnlyBlob = (raw, index) => {
269
+ const key = raw.key;
270
+ if (typeof key !== 'string' || !GRAPH_HASH_RE.test(key)) {
271
+ throw new RunUploadError('blob_key_invalid', `manifest.blobs[${index}].key must be a lowercase sha256: digest (got ${JSON.stringify(key ?? null)}).`);
272
+ }
273
+ const mime = raw.mime;
274
+ if (typeof mime !== 'string' || !mime.trim() || mime.length > 255) {
275
+ throw new RunUploadError('blob_mime_invalid', `manifest.blobs[${index}] (${key}) must carry a non-empty mime of at most 255 characters.`);
276
+ }
277
+ const size = raw.size;
278
+ if (typeof size !== 'number' || !Number.isInteger(size) || size < 0) {
279
+ throw new RunUploadError('blob_size_invalid', `manifest.blobs[${index}] (${key}) must carry a non-negative integer size.`);
280
+ }
281
+ const keyDigest = key.slice('sha256:'.length);
282
+ const sha = 'sha256' in raw ? raw.sha256 : keyDigest;
283
+ if (typeof sha !== 'string' || !SHA256_HEX_RE.test(sha) || sha !== keyDigest) {
284
+ throw new RunUploadError('blob_sha256_mismatch', `manifest.blobs[${index}] declares sha256 ${JSON.stringify(sha ?? null)}, which does not match its content-addressed key ${key}. ` +
285
+ 'The folder is corrupt; re-export the run.');
286
+ }
287
+ const out = { key, mime, size, sha256: sha };
288
+ if ('name' in raw) {
289
+ if (typeof raw.name !== 'string' || raw.name.length > 512) {
290
+ throw new RunUploadError('blob_name_invalid', `manifest.blobs[${index}] (${key}) name must be a string of at most 512 characters.`);
291
+ }
292
+ out.name = raw.name;
293
+ }
294
+ if ('artifactId' in raw) {
295
+ if (typeof raw.artifactId !== 'string' || !raw.artifactId.trim()) {
296
+ throw new RunUploadError('blob_artifact_id_invalid', `manifest.blobs[${index}] (${key}) artifactId must be a non-empty string.`);
297
+ }
298
+ out.artifactId = raw.artifactId;
299
+ }
300
+ if ('metadata' in raw) {
301
+ if (!isPlainObject(raw.metadata)) {
302
+ throw new RunUploadError('blob_metadata_invalid', `manifest.blobs[${index}] (${key}) metadata must be an object.`);
303
+ }
304
+ out.metadata = raw.metadata;
305
+ }
306
+ // Anything else on the folder entry — `file` above all — is deliberately
307
+ // dropped: the registry rejects a snapshot whose blob entries carry a path.
308
+ return out;
309
+ };
310
+ const assertBlobSetsAgree = (reachable, declared) => {
311
+ const reachableKeys = new Set(reachable.map(ref => ref.key));
312
+ const declaredKeys = new Set(declared.map(ref => ref.key));
313
+ const missing = [...reachableKeys].filter(key => !declaredKeys.has(key)).sort();
314
+ const unreferenced = [...declaredKeys].filter(key => !reachableKeys.has(key)).sort();
315
+ if (missing.length === 0 && unreferenced.length === 0)
316
+ return;
317
+ throw new RunUploadError('folder_corrupt', 'This run folder\'s blob table does not match the blobs its graph references, and Production Control requires an exact match. ' +
318
+ (missing.length > 0
319
+ ? `${missing.length} referenced blob(s) are absent from manifest.blobs (${missing.slice(0, 5).join(', ')}). `
320
+ : '') +
321
+ (unreferenced.length > 0
322
+ ? `${unreferenced.length} declared blob(s) are unreferenced by the graph (${unreferenced.slice(0, 5).join(', ')}). `
323
+ : '') +
324
+ 'Nothing was sent. Re-export the run.', { details: { missing, unreferenced } });
325
+ };
326
+ const assertBlobMetadataAgrees = (reachable, declared) => {
327
+ const byKey = new Map(declared.map(ref => [String(ref.key), ref]));
328
+ for (const ref of reachable) {
329
+ const entry = byKey.get(ref.key);
330
+ if (!entry)
331
+ continue;
332
+ for (const field of ['mime', 'size', 'sha256', 'name']) {
333
+ const value = ref[field];
334
+ if (value === undefined)
335
+ continue;
336
+ if (entry[field] !== value) {
337
+ throw new RunUploadError('folder_corrupt', `This run folder disagrees with itself about blob ${ref.key}: the graph reference says ${field}=${JSON.stringify(value)} ` +
338
+ `but manifest.blobs says ${field}=${JSON.stringify(entry[field] ?? null)}. Production Control rejects that. ` +
339
+ 'Nothing was sent. Re-export the run.', { details: { key: ref.key, field } });
340
+ }
341
+ }
342
+ }
343
+ };
344
+ const planBlobs = async (declared, folder) => {
345
+ const blobs = [];
346
+ const oversize = [];
347
+ for (const entry of declared) {
348
+ const blobKey = String(entry.key);
349
+ const file = folder.blobFiles.get(blobKey);
350
+ if (!file) {
351
+ throw new RunUploadError('blob_file_missing', `manifest.blobs declares ${blobKey} but gives no \`file\` for it, so its bytes are not in ${folder.path}. ` +
352
+ 'Nothing was sent. Re-export the run with its blobs.', { details: { blobKey } });
353
+ }
354
+ let stats;
355
+ try {
356
+ stats = await stat(file);
357
+ }
358
+ catch (error) {
359
+ throw new RunUploadError('blob_file_missing', `Run folder blob file is missing: ${file} (for ${blobKey}). Nothing was sent. Re-export the run with its blobs.`, { cause: error, details: { blobKey } });
360
+ }
361
+ const declaredSize = Number(entry.size);
362
+ if (stats.size !== declaredSize) {
363
+ throw new RunUploadError('blob_file_size_mismatch', `${file} is ${stats.size} bytes but manifest.blobs says ${declaredSize} for ${blobKey}. ` +
364
+ 'The folder is corrupt; nothing was sent. Re-export the run.', { details: { blobKey, onDisk: stats.size, declared: declaredSize } });
365
+ }
366
+ if (stats.size > CONTROL_MAX_BLOB_BYTES)
367
+ oversize.push({ key: blobKey, size: stats.size });
368
+ blobs.push({
369
+ blobKey,
370
+ sha256: String(entry.sha256),
371
+ mime: String(entry.mime),
372
+ name: typeof entry.name === 'string' && entry.name ? entry.name : blobKey,
373
+ size: stats.size,
374
+ file,
375
+ });
376
+ }
377
+ if (oversize.length > 0) {
378
+ throw new RunUploadError('blob_too_large', `${oversize.length} blob(s) in this run are larger than the ${formatBytes(CONTROL_MAX_BLOB_BYTES)} Production Control accepts per blob, ` +
379
+ `the largest being ${formatBytes(Math.max(...oversize.map(item => item.size)))}. Nothing was sent — this run cannot be uploaded as-is. ` +
380
+ 'Re-export it with downscaled assets, or ask for the per-blob limit to be raised.', { details: { blobKeys: oversize.map(item => item.key), limitBytes: CONTROL_MAX_BLOB_BYTES } });
381
+ }
382
+ return blobs;
383
+ };
384
+ // ---------------------------------------------------------------------------
385
+ // misc
386
+ // ---------------------------------------------------------------------------
387
+ const registrationMetadata = (folder, createdAt, generator) => {
388
+ const metadata = {
389
+ uploadedBy: 'assethub-cli',
390
+ runFolder: folder.name,
391
+ graphFolderCreatedAt: createdAt,
392
+ generator,
393
+ };
394
+ if (isPlainObject(folder.manifest.run))
395
+ metadata.run = folder.manifest.run;
396
+ if (isPlainObject(folder.manifest.source))
397
+ metadata.source = folder.manifest.source;
398
+ return metadata;
399
+ };
400
+ const normalizeTags = (tags) => [...new Set(tags.map(tag => String(tag ?? '').trim()).filter(Boolean))].sort();
401
+ const rejectUnknownFields = (value, allowed, label) => {
402
+ const allowedSet = new Set(allowed);
403
+ const unknown = Object.keys(value)
404
+ .filter(field => !allowedSet.has(field))
405
+ .sort();
406
+ if (unknown.length > 0) {
407
+ throw new RunUploadError('manifest_unknown_fields', `${label} carries fields the registry snapshot format does not allow: ${unknown.join(', ')}. ` +
408
+ 'Nothing was sent. Re-export the run with a current artifact-graph build.', { details: { fields: unknown } });
409
+ }
410
+ };
411
+ const assertUnderLimit = (label, bytes, limit) => {
412
+ if (bytes <= limit)
413
+ return;
414
+ throw new RunUploadError('payload_too_large', `This run's ${label} is ${formatBytes(bytes)}, over the ${formatBytes(limit)} the snapshot format allows. Nothing was sent.`, { details: { bytes, limitBytes: limit } });
415
+ };
416
+ const asNonNegativeInteger = (value) => {
417
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0)
418
+ return undefined;
419
+ return value;
420
+ };
421
+ const isoNoMs = (date) => `${date.toISOString().slice(0, 19)}Z`;
422
+ export const formatBytes = (bytes) => {
423
+ if (bytes < 1024)
424
+ return `${bytes} B`;
425
+ if (bytes < 1024 * 1024)
426
+ return `${(bytes / 1024).toFixed(1)} KiB`;
427
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
428
+ };
@@ -0,0 +1,5 @@
1
+ export declare const canonicalJson: (value: unknown) => string;
2
+ export declare const isPlainObject: (value: unknown) => value is Record<string, unknown>;
3
+ export declare const sha256Hex: (input: string) => string;
4
+ export declare const utf8ByteLength: (input: string) => number;
5
+ //# sourceMappingURL=canonicalJson.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canonicalJson.d.ts","sourceRoot":"","sources":["../../src/runsUpload/canonicalJson.ts"],"names":[],"mappings":"AAmBA,eAAO,MAAM,aAAa,GAAI,OAAO,OAAO,KAAG,MAI9C,CAAA;AAiFD,eAAO,MAAM,aAAa,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CACR,CAAA;AAEtE,eAAO,MAAM,SAAS,GAAI,OAAO,MAAM,KAAG,MACgB,CAAA;AAE1D,eAAO,MAAM,cAAc,GAAI,OAAO,MAAM,KAAG,MAA0C,CAAA"}
@@ -0,0 +1,108 @@
1
+ // Vendored from assethub-ml
2
+ // research/artifact-graph/artifact-graph-js/src/graph/commit-reducer.js
3
+ // (canonicalJson and its private helpers ONLY), via the identical vendoring in
4
+ // apps/frontend/src/feature/artifactGraph/export/canonicalJson.ts.
5
+ //
6
+ // These produce byte-identical output to the artifact-graph Python library's
7
+ // `json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)`
8
+ // — which is what the registry recomputes a pushed snapshot's graphHash with.
9
+ // Any drift here makes every push fail validation with an opaque 400, so do NOT
10
+ // edit without re-running the golden vector in
11
+ // src/__tests__/runsUploadPlan.test.ts.
12
+ //
13
+ // One deliberate divergence from the web copy: `sha256Hex` delegates to
14
+ // node:crypto instead of carrying a hand-rolled SHA-256. The web copy runs in
15
+ // the browser/edge bundle and cannot; the CLI is Node-only, and the digest of
16
+ // the same bytes is the same digest.
17
+ import { createHash } from 'node:crypto';
18
+ export const canonicalJson = (value) => {
19
+ const out = [];
20
+ writeCanonicalJson(value, out);
21
+ return out.join('');
22
+ };
23
+ // Sorted-key JSON with numbers rendered exactly as Python's json.dumps renders
24
+ // the reducer's coerced values, so both runtimes produce byte-identical
25
+ // canonical strings (and therefore graph hashes) for the same graph.
26
+ const writeCanonicalJson = (value, out) => {
27
+ if (value === null || value === undefined) {
28
+ out.push('null');
29
+ return;
30
+ }
31
+ const type = typeof value;
32
+ if (type === 'string') {
33
+ out.push(JSON.stringify(value));
34
+ return;
35
+ }
36
+ if (type === 'boolean') {
37
+ out.push(value ? 'true' : 'false');
38
+ return;
39
+ }
40
+ if (type === 'number') {
41
+ out.push(canonicalNumber(value));
42
+ return;
43
+ }
44
+ if (Array.isArray(value)) {
45
+ out.push('[');
46
+ for (let index = 0; index < value.length; index += 1) {
47
+ if (index > 0)
48
+ out.push(',');
49
+ writeCanonicalJson(value[index] === undefined ? null : value[index], out);
50
+ }
51
+ out.push(']');
52
+ return;
53
+ }
54
+ if (isPlainObject(value)) {
55
+ out.push('{');
56
+ let first = true;
57
+ for (const key of Object.keys(value).sort()) {
58
+ const entry = value[key];
59
+ if (entry === undefined || typeof entry === 'function' || typeof entry === 'symbol')
60
+ continue;
61
+ if (!first)
62
+ out.push(',');
63
+ first = false;
64
+ out.push(JSON.stringify(key), ':');
65
+ writeCanonicalJson(entry, out);
66
+ }
67
+ out.push('}');
68
+ return;
69
+ }
70
+ out.push(JSON.stringify(value) ?? 'null');
71
+ };
72
+ const canonicalNumber = (value) => {
73
+ if (!Number.isFinite(value))
74
+ return 'null';
75
+ // Integral doubles below 2^53 print as plain digits, matching the Python
76
+ // reducer, which coerces exactly that range to int at ingestion. Above
77
+ // 2^53 exact-integer digits and shortest-digit rendering diverge, so those
78
+ // take the float path on both sides.
79
+ if (Number.isInteger(value) && Math.abs(value) < 9007199254740992)
80
+ return String(value);
81
+ return pythonFloatRepr(value);
82
+ };
83
+ // CPython float repr for a finite double: shortest round-trip digits, fixed
84
+ // notation while -4 < decpt <= 16 (with a trailing ".0" for integral values),
85
+ // otherwise scientific with a signed, zero-padded-to-two-digits exponent
86
+ // (1e-05, 1.5e+22). The shortest-digit sequence itself is identical in both
87
+ // runtimes; only the placement rules differ from ECMAScript's String(x).
88
+ const pythonFloatRepr = (value) => {
89
+ const exponential = value.toExponential();
90
+ const [mantissa, exponentRaw] = exponential.split('e');
91
+ const sign = mantissa.startsWith('-') ? '-' : '';
92
+ const digits = mantissa.replace('-', '').replace('.', '');
93
+ const decpt = Number(exponentRaw) + 1;
94
+ if (decpt > -4 && decpt <= 16) {
95
+ if (decpt <= 0)
96
+ return `${sign}0.${'0'.repeat(-decpt)}${digits}`;
97
+ if (decpt >= digits.length)
98
+ return `${sign}${digits}${'0'.repeat(decpt - digits.length)}.0`;
99
+ return `${sign}${digits.slice(0, decpt)}.${digits.slice(decpt)}`;
100
+ }
101
+ const exponent = decpt - 1;
102
+ const exponentDigits = String(Math.abs(exponent)).padStart(2, '0');
103
+ const fraction = digits.length > 1 ? `.${digits.slice(1)}` : '';
104
+ return `${sign}${digits[0]}${fraction}e${exponent < 0 ? '-' : '+'}${exponentDigits}`;
105
+ };
106
+ export const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
107
+ export const sha256Hex = (input) => createHash('sha256').update(input, 'utf8').digest('hex');
108
+ export const utf8ByteLength = (input) => Buffer.byteLength(input, 'utf8');
@@ -0,0 +1,63 @@
1
+ /**
2
+ * THE ONE PLACE the Production Control run-upload wire contract lives.
3
+ *
4
+ * The backend for these routes is being built in parallel. Everything the CLI
5
+ * knows about *where* to send bytes and *how big* they may be is in this file,
6
+ * so a late change on the server side is a one-line edit here rather than a
7
+ * sweep through the transport.
8
+ *
9
+ * Route shape mirrors the Cloudflare ag-registry Worker
10
+ * (assethub-ml research/artifact-graph/cloud/registry-worker/src/worker.js)
11
+ * because the payloads are the same `ag.registry.v1` documents. Only the prefix
12
+ * and the transport envelope differ: these live on AssetHub's public v2 API
13
+ * surface, behind `withExposedApi()` + API-key auth, so they speak AssetHub's
14
+ * `{success, data}` / `{success, error}` envelope rather than
15
+ * `ag.registry-error.v1`.
16
+ */
17
+ /** Change this one line if the backend lands on a different prefix. */
18
+ export declare const CONTROL_RUN_UPLOAD_API_PREFIX = "/api/v2/production/run-uploads";
19
+ export type ControlRunUploadUrls = {
20
+ /** POST — register the graph. Required before any blob or snapshot lands. */
21
+ graphs: string;
22
+ /** PUT — the `ag.registry-push.v1` envelope. */
23
+ snapshot: (graphId: string) => string;
24
+ /** HEAD (does the pool already hold these bytes?) / PUT (send them). */
25
+ blob: (graphId: string, blobKey: string) => string;
26
+ };
27
+ export declare const controlRunUploadUrls: (baseUrl: string) => ControlRunUploadUrls;
28
+ /**
29
+ * Server-enforced ceilings, checked locally so a doomed run is rejected before
30
+ * the first byte leaves rather than after N blobs have already landed.
31
+ *
32
+ * The `ag.registry.v1` format allows far more (64 MiB graph arrays, 2 MiB
33
+ * manifest). These smaller numbers are the real ceiling: the routes run on
34
+ * Vercel Serverless, whose request-body limit bites first.
35
+ */
36
+ export declare const CONTROL_MAX_BLOB_BYTES: number;
37
+ export declare const CONTROL_MAX_PUSH_BYTES: number;
38
+ /** Format-level ceilings from the registry contract, checked for completeness. */
39
+ export declare const REGISTRY_MAX_MANIFEST_BYTES: number;
40
+ export declare const REGISTRY_MAX_GRAPH_ARRAY_BYTES: number;
41
+ export declare const REGISTRY_MAX_METADATA_BYTES: number;
42
+ /**
43
+ * Error codes the routes answer with. Values follow AssetHub's public-API
44
+ * SCREAMING_SNAKE convention; the semantics follow the registry's.
45
+ */
46
+ export declare const CONTROL_ERROR_CODES: {
47
+ /** 409 — the snapshot references bytes the pool does not hold yet. */
48
+ readonly missingBlobs: "MISSING_BLOBS";
49
+ /** 409 — same rev, different graphHash. A genuine divergence. */
50
+ readonly snapshotConflict: "SNAPSHOT_CONFLICT";
51
+ /** 409 — the server already holds a newer rev. Desired end state, not a failure. */
52
+ readonly staleSnapshot: "STALE_SNAPSHOT";
53
+ readonly payloadTooLarge: "PAYLOAD_TOO_LARGE";
54
+ /** 404 — the graph was never registered by this org. Distinct from "no access". */
55
+ readonly notRegistered: "NOT_REGISTERED";
56
+ };
57
+ /**
58
+ * These routes are internal-only. An API key whose owner is not an internal
59
+ * user, or whose org lacks the Production Control entitlement, gets 404 rather
60
+ * than 403 — the path's existence is itself not disclosed.
61
+ */
62
+ export declare const CONTROL_NOT_ENTITLED_STATUS = 404;
63
+ //# sourceMappingURL=controlEndpoints.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"controlEndpoints.d.ts","sourceRoot":"","sources":["../../src/runsUpload/controlEndpoints.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,uEAAuE;AACvE,eAAO,MAAM,6BAA6B,mCAAmC,CAAA;AAI7E,MAAM,MAAM,oBAAoB,GAAG;IACjC,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAA;IACd,gDAAgD;IAChD,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAA;IACrC,wEAAwE;IACxE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAA;CACnD,CAAA;AAED,eAAO,MAAM,oBAAoB,GAAI,SAAS,MAAM,KAAG,oBAUtD,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,QAAkB,CAAA;AACrD,eAAO,MAAM,sBAAsB,QAAkB,CAAA;AAErD,kFAAkF;AAClF,eAAO,MAAM,2BAA2B,QAAkB,CAAA;AAC1D,eAAO,MAAM,8BAA8B,QAAmB,CAAA;AAC9D,eAAO,MAAM,2BAA2B,QAAa,CAAA;AAErD;;;GAGG;AACH,eAAO,MAAM,mBAAmB;IAC9B,sEAAsE;;IAEtE,iEAAiE;;IAEjE,oFAAoF;;;IAGpF,mFAAmF;;CAE3E,CAAA;AAEV;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAA"}