@shardflux/sdk 0.6.1 → 0.7.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/templates.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ShardfluxApiError } from "./errors.js";
2
2
  import { pollWithWait, randomId } from "./http.js";
3
+ import { TemplateFileError, UPLOAD_BYTES_MAX, assertInside, fileBody, nodeModules, normalizeTemplateDocument, readTemplateFile, resolveLocalSource, tempDir } from "./template-file.js";
3
4
  import { Workspace } from "./workspace.js";
4
5
  const TERMINAL = new Set(['succeeded', 'failed', 'canceled']);
5
6
  /** Finished from the caller's point of view: failed/canceled/legacy succeeded, or published and its registration settled. */
@@ -31,6 +32,8 @@ export function saveAsTemplateBody(params) {
31
32
  body.description = params.description;
32
33
  if (params.defaults !== undefined)
33
34
  body.defaults = params.defaults;
35
+ if (params.settings !== undefined)
36
+ body.settings = params.settings;
34
37
  if (params.checkpointId !== undefined)
35
38
  body.checkpoint_id = params.checkpointId;
36
39
  if (params.autoPublish !== undefined)
@@ -39,6 +42,16 @@ export function saveAsTemplateBody(params) {
39
42
  body.acknowledged_scan_findings = params.acknowledgedScanFindings;
40
43
  return body;
41
44
  }
45
+ /** A 200/202 open-shaped answer (drafts, test instances) as a Workspace handle, waiting for the operation unless `wait: false`. */
46
+ async function openedWorkspace(ctx, res, p) {
47
+ const wrap = { agentLabel: p.agentLabel, tools: p.tools };
48
+ if (res.status === 200 || p.wait === false || res.body.operation === null) {
49
+ return { workspace: new Workspace(ctx, res.body.workspace, { ...wrap, token: res.body.tool_token }), operation: res.body.operation };
50
+ }
51
+ const operation = await ctx.workspaces.waitForOperation(res.body.operation.id, p.wait ?? {});
52
+ const view = await ctx.http.json('GET', `/v1/workspaces/${enc(res.body.workspace.id)}`, {}, ctx.authorization);
53
+ return { workspace: new Workspace(ctx, view, wrap), operation };
54
+ }
42
55
  /**
43
56
  * Template dev mode for one organization template (contracts §19.9): the single live draft (a layered, persistent
44
57
  * workspace on the draft base), its captured states, disposable test instances (session workspaces on a copy of a
@@ -57,15 +70,8 @@ export class TemplateDraftApi {
57
70
  #path(suffix = '') {
58
71
  return `${templatePath(this.slug, this.#org)}/draft${suffix}`;
59
72
  }
60
- async #opened(res, p) {
61
- const ctx = this.#ctx();
62
- const wrap = { agentLabel: p.agentLabel, tools: p.tools };
63
- if (res.status === 200 || p.wait === false || res.body.operation === null) {
64
- return { workspace: new Workspace(ctx, res.body.workspace, { ...wrap, token: res.body.tool_token }), operation: res.body.operation };
65
- }
66
- const operation = await ctx.workspaces.waitForOperation(res.body.operation.id, p.wait ?? {});
67
- const view = await ctx.http.json('GET', `/v1/workspaces/${enc(res.body.workspace.id)}`, {}, ctx.authorization);
68
- return { workspace: new Workspace(ctx, view, wrap), operation };
73
+ #opened(res, p) {
74
+ return openedWorkspace(this.#ctx(), res, p);
69
75
  }
70
76
  /**
71
77
  * Opens the template's draft on `base` (202; waits until ready unless `wait: false`). 409 template_not_layered (the
@@ -75,6 +81,10 @@ export class TemplateDraftApi {
75
81
  const body = {};
76
82
  if (params.base !== undefined)
77
83
  body.base = params.base;
84
+ if (params.displayName !== undefined)
85
+ body.display_name = params.displayName;
86
+ if (params.inputs !== undefined)
87
+ body.inputs = params.inputs;
78
88
  if (params.caps !== undefined)
79
89
  body.caps = params.caps;
80
90
  if (params.projectId !== undefined)
@@ -146,6 +156,8 @@ export class TemplateDraftApi {
146
156
  body.agent_label = params.agentLabel;
147
157
  if (params.tools !== undefined)
148
158
  body.tools = params.tools;
159
+ if (params.inputs !== undefined)
160
+ body.inputs = params.inputs;
149
161
  const ctx = this.#ctx();
150
162
  const res = await ctx.http.jsonWithStatus('POST', this.#path('/test-instances'), { json: body, idempotencyKey: params.idempotencyKey ?? randomId('test-') }, ctx.authorization);
151
163
  return (await this.#opened(res, params)).workspace;
@@ -168,6 +180,8 @@ export class TemplateDraftApi {
168
180
  body.description = params.description;
169
181
  if (params.defaults !== undefined)
170
182
  body.defaults = params.defaults;
183
+ if (params.settings !== undefined)
184
+ body.settings = params.settings;
171
185
  if (params.autoPublish !== undefined)
172
186
  body.auto_publish = params.autoPublish;
173
187
  if (params.acknowledgedScanFindings !== undefined)
@@ -181,13 +195,22 @@ export class TemplateBuildsApi {
181
195
  constructor(ctx) {
182
196
  this.#ctx = ctx;
183
197
  }
184
- /** Queues a build (202). Poll with get()/waitForBuild(); `builder_availability` says whether a builder runs. */
198
+ /**
199
+ * Queues a build (202) of a recipe v1 (Dockerfile) or recipe v2 (0.7.0). Poll with get()/waitForBuild();
200
+ * `builder_availability` says whether a builder runs. A recipe v2 is validated at once: 422 validation_failed with
201
+ * details.reason (invalid_recipe, base_not_layered, language_unavailable, upload_missing, invalid_settings, ...) and
202
+ * details.field (the JSON path). A file entry still carrying `from` is 422 upload_required.
203
+ */
185
204
  create(organizationId, params) {
186
205
  const body = { template_slug: params.templateSlug, recipe: params.recipe };
187
206
  if (params.displayName !== undefined)
188
207
  body.display_name = params.displayName;
189
208
  if (params.autoPublish !== undefined)
190
209
  body.auto_publish = params.autoPublish;
210
+ if (params.description !== undefined)
211
+ body.description = params.description;
212
+ if (params.acknowledgedScanFindings !== undefined)
213
+ body.acknowledged_scan_findings = params.acknowledgedScanFindings;
191
214
  return this.#ctx().http.json('POST', `/v1/organizations/${enc(organizationId)}/template-builds`, { json: body, idempotencyKey: params.idempotencyKey ?? randomId('template-build-') }, this.#ctx().authorization);
192
215
  }
193
216
  get(organizationId, buildId) {
@@ -230,15 +253,23 @@ export class TemplateBuildsApi {
230
253
  const waitS = opts.serverWait === false ? 0 : (timeoutMs - (Date.now() - started)) / 1000;
231
254
  const t0 = Date.now();
232
255
  const { body: build, applied } = await pollWithWait(http, `/v1/organizations/${enc(organizationId)}/template-builds/${enc(buildId)}`, authorization, waitS, opts.signal);
256
+ const key = `${build.state}/${build.registration.state}`;
257
+ const changed = key !== lastKey;
258
+ lastKey = key;
259
+ if (changed && opts.onChange) {
260
+ try {
261
+ opts.onChange(build);
262
+ }
263
+ catch {
264
+ // A listener never breaks the wait.
265
+ }
266
+ }
233
267
  if (buildSettled(build))
234
268
  return build;
235
269
  const waited = Date.now() - started;
236
270
  if (waited >= timeoutMs)
237
271
  throw new TemplateBuildTimeoutError(build, waited);
238
272
  // Held by the server (or a change): poll again at once; a quick unchanged answer falls through to the backoff.
239
- const key = `${build.state}/${build.registration.state}`;
240
- const changed = key !== lastKey;
241
- lastKey = key;
242
273
  if (applied && (changed || Date.now() - t0 >= 1_000))
243
274
  continue;
244
275
  await sleep(Math.max(10, Math.min(interval, timeoutMs - waited)));
@@ -248,12 +279,400 @@ export class TemplateBuildsApi {
248
279
  }
249
280
  }
250
281
  }
282
+ /** A presigned PUT the storage refused (the URL is a bearer credential and never part of the message). */
283
+ export class TemplateUploadError extends Error {
284
+ /** HTTP status of the storage's answer (0: no answer, a network failure). */
285
+ status;
286
+ /** The storage's error code: BadDigest (other bytes), SignatureDoesNotMatch (another length or header), ... */
287
+ code;
288
+ sha256;
289
+ constructor(sha256, status, code, detail) {
290
+ super(`Upload of sha256:${sha256} was refused by the storage: ${status === 0 ? (detail ?? 'network error') : `HTTP ${status}${code ? ` ${code}` : ''}`}${code === 'BadDigest' ? ' (the bytes do not match their SHA-256)' : code === 'SignatureDoesNotMatch' ? ' (another length, or a header was changed)' : ''}`);
291
+ this.name = 'TemplateUploadError';
292
+ this.status = status;
293
+ this.code = code;
294
+ this.sha256 = sha256;
295
+ }
296
+ }
297
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
298
+ function hex(buf) {
299
+ return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join('');
300
+ }
301
+ async function sha256Of(bytes) {
302
+ // DOM typings (browser bundles) take only ArrayBuffer-backed views; a SharedArrayBuffer view is copied.
303
+ const view = bytes.buffer instanceof ArrayBuffer ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : new Uint8Array(bytes);
304
+ return hex(await crypto.subtle.digest('SHA-256', view));
305
+ }
306
+ async function readStream(stream) {
307
+ const chunks = [];
308
+ let n = 0;
309
+ for await (const c of stream) {
310
+ chunks.push(c);
311
+ n += c.length;
312
+ if (n > UPLOAD_BYTES_MAX)
313
+ throw new TemplateFileError(`an upload is at most ${UPLOAD_BYTES_MAX} bytes (5 GiB)`);
314
+ }
315
+ const out = new Uint8Array(n);
316
+ let at = 0;
317
+ for (const c of chunks) {
318
+ out.set(c, at);
319
+ at += c.length;
320
+ }
321
+ return out;
322
+ }
323
+ function uploadsPath(organizationId) {
324
+ return organizationId === undefined ? '/v1/template-uploads' : `/v1/organizations/${enc(organizationId)}/template-uploads`;
325
+ }
326
+ async function requestUpload(ctx, meta, organizationId, signal) {
327
+ return ctx.http.jsonWithStatus('POST', uploadsPath(organizationId), { json: meta, ...(signal ? { signal } : {}) }, ctx.authorization);
328
+ }
329
+ /**
330
+ * POST …/template-uploads; on 201 PUT the bytes with exactly the presigned headers, then ask again so the answer says
331
+ * `available`. On 200 nothing is sent (the organization already has the bytes).
332
+ */
333
+ async function sendUpload(ctx, meta, body, organizationId, signal) {
334
+ const ref = `sha256:${meta.sha256}`;
335
+ const first = await requestUpload(ctx, meta, organizationId, signal);
336
+ const put = first.body.put;
337
+ if (first.status === 200 || put === null)
338
+ return { upload: first.body.upload, ref, uploaded: false };
339
+ const attempts = body.replayable ? 3 : 1;
340
+ for (let attempt = 1;; attempt += 1) {
341
+ let res;
342
+ try {
343
+ const init = { method: put.method, headers: { ...put.headers }, body: await body.open(), ...(signal ? { signal } : {}) };
344
+ if (init.body instanceof ReadableStream)
345
+ init.duplex = 'half';
346
+ res = await ctx.fetch(put.url, init);
347
+ }
348
+ catch (err) {
349
+ if (signal?.aborted)
350
+ throw err;
351
+ if (attempt >= attempts)
352
+ throw new TemplateUploadError(meta.sha256, 0, null, err instanceof Error ? (err.cause instanceof Error ? err.cause.message : err.message) : String(err));
353
+ await ctx.sleep(500 * 2 ** (attempt - 1));
354
+ continue;
355
+ }
356
+ if (res.ok) {
357
+ await res.body?.cancel().catch(() => undefined);
358
+ break;
359
+ }
360
+ const text = await res.text().catch(() => '');
361
+ const code = /<Code>([^<]+)<\/Code>/.exec(text)?.[1] ?? null;
362
+ if (res.status >= 500 && attempt < attempts) {
363
+ await ctx.sleep(500 * 2 ** (attempt - 1));
364
+ continue;
365
+ }
366
+ throw new TemplateUploadError(meta.sha256, res.status, code);
367
+ }
368
+ const again = await requestUpload(ctx, meta, organizationId, signal);
369
+ return { upload: again.body.upload, ref, uploaded: true };
370
+ }
371
+ /**
372
+ * Build uploads (contracts §24.2): files and folders a recipe v2 copies into the template, stored once per
373
+ * organization and content (SHA-256). Needs build access (API keys with a tool permission). Uploads count toward the
374
+ * organization's template storage while they exist; one nothing references is deleted 7 days later.
375
+ */
376
+ export class TemplateUploadsApi {
377
+ #ctx;
378
+ constructor(ctx) {
379
+ this.#ctx = ctx;
380
+ }
381
+ /**
382
+ * POST …/template-uploads alone: `status` 200 (the organization has the bytes; `put` null) or 201 (`put`: the
383
+ * presigned PUT, valid 900 s). 422 upload_too_large (over 5 GiB), upload_digest_mismatch (the same SHA-256 with
384
+ * another size); 503 dependency_unavailable (uploads_not_configured).
385
+ */
386
+ async request(meta, opts = {}) {
387
+ const res = await requestUpload(this.#ctx(), meta, opts.organizationId, opts.signal);
388
+ return { status: res.status, ...res.body };
389
+ }
390
+ /**
391
+ * Uploads bytes unless the organization already has them, and returns the `sha256:<hex>` reference for a recipe v2
392
+ * file entry. The PUT carries exactly the presigned headers (the storage checks the SHA-256 and the length). A stream
393
+ * is sent as it is when `sha256` and `size` are given (it cannot be retried); otherwise it is read into memory first.
394
+ * Throws TemplateUploadError when the storage refuses the bytes.
395
+ */
396
+ async put(data, opts) {
397
+ let sha256 = opts.sha256;
398
+ let size = opts.size;
399
+ let body;
400
+ if (data instanceof ReadableStream) {
401
+ if (sha256 === undefined || size === undefined) {
402
+ const bytes = await readStream(data);
403
+ sha256 ??= await sha256Of(bytes);
404
+ size ??= bytes.length;
405
+ body = { open: () => Promise.resolve(bytes), replayable: true };
406
+ }
407
+ else {
408
+ let used = false;
409
+ body = {
410
+ open: () => {
411
+ if (used)
412
+ throw new TemplateFileError('the upload stream was already sent');
413
+ used = true;
414
+ return Promise.resolve(data);
415
+ },
416
+ replayable: false,
417
+ };
418
+ }
419
+ }
420
+ else if (data instanceof Blob) {
421
+ size ??= data.size;
422
+ sha256 ??= await sha256Of(new Uint8Array(await data.arrayBuffer()));
423
+ body = { open: () => Promise.resolve(data), replayable: true };
424
+ }
425
+ else {
426
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
427
+ size ??= bytes.length;
428
+ sha256 ??= await sha256Of(bytes);
429
+ body = { open: () => Promise.resolve(bytes), replayable: true };
430
+ }
431
+ if (!SHA256_HEX.test(sha256))
432
+ throw new TemplateFileError(`sha256 must be 64 lower-case hex digits (got "${sha256}")`);
433
+ if (size > UPLOAD_BYTES_MAX)
434
+ throw new TemplateFileError(`an upload is at most ${UPLOAD_BYTES_MAX} bytes (5 GiB); got ${size}`);
435
+ const result = await sendUpload(this.#ctx(), { sha256, size, kind: opts.kind }, body, opts.organizationId, opts.signal);
436
+ if (data instanceof ReadableStream && !result.uploaded)
437
+ await data.cancel().catch(() => undefined);
438
+ return result;
439
+ }
440
+ /**
441
+ * Node only: uploads a local file, or a folder packed as the reproducible tar (the same bytes as the Python SDK's
442
+ * for the same folder). `kind` omitted: a folder is `tar`, a file `file`; a file with kind `tar` is a prepared,
443
+ * uncompressed archive.
444
+ */
445
+ async putPath(path, opts = {}) {
446
+ const tmp = tempDir();
447
+ try {
448
+ const src = await resolveLocalSource(path, opts.kind, { baseDir: '.', tmpDir: tmp.get });
449
+ const result = await uploadLocal(this.#ctx(), src, opts.organizationId, opts.signal);
450
+ return { ...result, path: src.path, kind: src.kind, size: src.size, entries: src.entries };
451
+ }
452
+ finally {
453
+ await tmp.cleanup();
454
+ }
455
+ }
456
+ }
457
+ function uploadLocal(ctx, src, organizationId, signal) {
458
+ return sendUpload(ctx, { sha256: src.sha256, size: src.size, kind: src.kind }, { open: () => fileBody(src.uploadPath), replayable: true }, organizationId, signal);
459
+ }
460
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
461
+ // ---- recipe export, version test instances, package search (contracts §24.6) --------------------------------------------
462
+ export class TemplateVersionsApi {
463
+ #ctx;
464
+ constructor(ctx) {
465
+ this.#ctx = ctx;
466
+ }
467
+ /**
468
+ * The recipe and settings a version was built from, in request form (`recipe` null for versions saved from a
469
+ * workspace and platform versions). Building the recipe again (same base, uploads still present) gives the same
470
+ * recipe_sha256. Same visibility as the version.
471
+ */
472
+ recipe(slug, version, params = {}) {
473
+ const ctx = this.#ctx();
474
+ return ctx.http.json('GET', `${templatePath(slug, params.organizationId)}/versions/${enc(String(version))}/recipe`, { query: { owner: params.owner } }, ctx.authorization);
475
+ }
476
+ }
477
+ /**
478
+ * Test instances of a version (contracts §24.6): a session workspace on a registered version of the organization's
479
+ * template, published or not, so a build can be tried before it is published. Owners, admins and API keys with a
480
+ * tool permission (403 template_dev_mode_role otherwise).
481
+ */
482
+ export class TemplateVersionTestInstancesApi {
483
+ #ctx;
484
+ constructor(ctx) {
485
+ this.#ctx = ctx;
486
+ }
487
+ /** Opens one (202; waits until ready unless `wait: false`). It ends with close() or when idle. */
488
+ async create(slug, version, params = {}) {
489
+ const body = {};
490
+ if (params.key !== undefined)
491
+ body.key = params.key;
492
+ if (params.caps !== undefined)
493
+ body.caps = params.caps;
494
+ if (params.agentLabel !== undefined)
495
+ body.agent_label = params.agentLabel;
496
+ if (params.tools !== undefined)
497
+ body.tools = params.tools;
498
+ if (params.inputs !== undefined)
499
+ body.inputs = params.inputs;
500
+ if (params.projectId !== undefined)
501
+ body.project_id = params.projectId;
502
+ const ctx = this.#ctx();
503
+ const res = await ctx.http.jsonWithStatus('POST', `${templatePath(slug, params.organizationId)}/versions/${enc(String(version))}/test-instances`, { json: body, idempotencyKey: params.idempotencyKey ?? randomId('test-') }, ctx.authorization);
504
+ return (await openedWorkspace(ctx, res, params)).workspace;
505
+ }
506
+ }
507
+ /** Package names for the editor's pickers (contracts §24.6): apt (a base's index), pip (names only) and npm. */
508
+ export class TemplatePackagesApi {
509
+ #ctx;
510
+ constructor(ctx) {
511
+ this.#ctx = ctx;
512
+ }
513
+ #path(organizationId, suffix = '') {
514
+ return organizationId === undefined ? `/v1/template-packages${suffix}` : `/v1/organizations/${enc(organizationId)}/template-packages${suffix}`;
515
+ }
516
+ /**
517
+ * Searches an ecosystem (`query` 1..100 characters; `limit` 1..50, default 20). apt needs `base` (`<slug>@<version>`);
518
+ * a base without an index is 409 package_index_unavailable. pip returns names only (fetch one package for versions).
519
+ * 503 dependency_unavailable (package_search_unavailable, package_index_loading) and 429 are retryable.
520
+ */
521
+ search(ecosystem, query, params = {}) {
522
+ const ctx = this.#ctx();
523
+ return ctx.http.json('GET', this.#path(params.organizationId), { query: { ecosystem, q: query, base: params.base, limit: params.limit } }, ctx.authorization);
524
+ }
525
+ /** One package: its latest version, summary and known versions (404 package_not_found). */
526
+ get(ecosystem, name, params = {}) {
527
+ const ctx = this.#ctx();
528
+ return ctx.http.json('GET', this.#path(params.organizationId, `/${enc(ecosystem)}/${enc(name)}`), { query: { base: params.base } }, ctx.authorization);
529
+ }
530
+ }
251
531
  export class TemplatesApi {
252
532
  #ctx;
253
533
  builds;
534
+ /** Build uploads (0.7.0): files and folders for recipe v2. */
535
+ uploads;
536
+ /** Recipe export of a version (0.7.0). */
537
+ versions;
538
+ /** Test instances of a registered version, published or not (0.7.0). */
539
+ versionTestInstances;
540
+ /** Package search for recipes (0.7.0). */
541
+ packages;
542
+ #organization;
254
543
  constructor(ctx) {
255
544
  this.#ctx = ctx;
256
545
  this.builds = new TemplateBuildsApi(ctx);
546
+ this.uploads = new TemplateUploadsApi(ctx);
547
+ this.versions = new TemplateVersionsApi(ctx);
548
+ this.versionTestInstances = new TemplateVersionTestInstancesApi(ctx);
549
+ this.packages = new TemplatePackagesApi(ctx);
550
+ }
551
+ /**
552
+ * The languages `base` (`<slug>@<version>`) offers a recipe v2's `build.languages` (0.7.0): the platform's table for
553
+ * the chain's platform base, in table order. `included`: the base already has that version (nothing is installed and
554
+ * no host is needed); a version the base has another version of is left out (a build would refuse it with
555
+ * language_conflict). `hosts` and `apt` are what the language adds to an `auto` build network. An unknown or
556
+ * archived base is 422 validation_failed with details.field `base`. Build access (API keys with a tool permission).
557
+ */
558
+ languages(base, params = {}) {
559
+ const ctx = this.#ctx();
560
+ const path = params.organizationId === undefined ? '/v1/template-languages' : `/v1/organizations/${enc(params.organizationId)}/template-languages`;
561
+ return ctx.http.json('GET', path, { query: { base } }, ctx.authorization);
562
+ }
563
+ /** The API key's organization (GET /v1/me, once per client). */
564
+ #organizationId() {
565
+ this.#organization ??= (async () => {
566
+ const ctx = this.#ctx();
567
+ const me = await ctx.http.json('GET', '/v1/me', {}, ctx.authorization);
568
+ const org = me.api_key?.organization_id;
569
+ if (!org)
570
+ throw new TemplateFileError('this principal has no API key organization; pass organizationId');
571
+ return org;
572
+ })();
573
+ this.#organization.catch(() => (this.#organization = undefined));
574
+ return this.#organization;
575
+ }
576
+ /**
577
+ * Node only: builds a template from template.yaml (or a .json file with the same document; contracts §24.1). The
578
+ * file is recipe v2; each `build.files[]` entry may name a local `from` path (relative to the file): folders are
579
+ * packed as the reproducible tar, files are sent as they are, both uploaded unless the organization already has
580
+ * them, and the build is created (and awaited with `wait`). YAML needs the optional `yaml` package or `parseYaml`.
581
+ */
582
+ async buildFromFile(file, opts) {
583
+ const { path } = await nodeModules();
584
+ const resolved = path.resolve(file);
585
+ if (opts.root !== undefined)
586
+ await assertInside(opts.root, resolved, file);
587
+ const doc = await readTemplateFile(resolved, opts.parseYaml ? { parseYaml: opts.parseYaml } : {});
588
+ return this.buildFromRecipe(doc, { ...opts, baseDir: path.dirname(resolved) });
589
+ }
590
+ /**
591
+ * Builds a recipe v2 document whose file entries may name local `from` paths (Node only when one does): the same
592
+ * as buildFromFile() for a document already in memory, `from` relative to `baseDir`.
593
+ */
594
+ async buildFromRecipe(recipe, opts) {
595
+ const doc = normalizeTemplateDocument(structuredClone(recipe), 'the recipe');
596
+ const build = isRecord(doc.build) ? { ...doc.build } : null;
597
+ const files = build && Array.isArray(build.files) ? [...build.files] : null;
598
+ const uploads = [];
599
+ const tmp = tempDir();
600
+ const emit = (e) => {
601
+ try {
602
+ opts.onProgress?.(e);
603
+ }
604
+ catch {
605
+ // A listener never breaks the build.
606
+ }
607
+ };
608
+ try {
609
+ if (files && build) {
610
+ const done = new Map();
611
+ const orgForUploads = opts.organizationId;
612
+ for (let i = 0; i < files.length; i += 1) {
613
+ const f = files[i];
614
+ if (!isRecord(f) || f.from === undefined)
615
+ continue;
616
+ const where = `build.files[${i}]`;
617
+ if (typeof f.from !== 'string' || f.from.length === 0)
618
+ throw new TemplateFileError(`${where}.from must be a local path`);
619
+ if (f.upload !== undefined)
620
+ throw new TemplateFileError(`${where} has both from and upload; keep one`);
621
+ if (f.kind !== undefined && f.kind !== 'file' && f.kind !== 'tar')
622
+ throw new TemplateFileError(`${where}.kind must be file or tar`);
623
+ const { path } = await nodeModules();
624
+ const baseDir = opts.baseDir ?? process.cwd();
625
+ const key = `${path.resolve(baseDir, f.from)}\0${f.kind ?? ''}`;
626
+ let hit = done.get(key);
627
+ if (!hit) {
628
+ const t0 = Date.now();
629
+ const src = await resolveLocalSource(f.from, f.kind, { baseDir, root: opts.root, tmpDir: tmp.get });
630
+ emit({ type: 'pack', from: f.from, path: src.path, kind: src.kind, sha256: src.sha256, size: src.size, entries: src.entries, ms: Date.now() - t0 });
631
+ const t1 = Date.now();
632
+ const result = await uploadLocal(this.#ctx(), src, orgForUploads, opts.signal);
633
+ emit({ type: 'upload', from: f.from, sha256: src.sha256, size: src.size, uploaded: result.uploaded, ms: Date.now() - t1 });
634
+ hit = { src, uploaded: result.uploaded };
635
+ done.set(key, hit);
636
+ // One row per distinct local source (as the Python SDK reports them); `to` is its first entry's.
637
+ uploads.push({ from: f.from, path: src.path, to: typeof f.to === 'string' ? f.to : '', kind: src.kind, sha256: src.sha256, size: src.size, entries: src.entries, uploaded: result.uploaded });
638
+ }
639
+ const rest = { ...f };
640
+ delete rest.from;
641
+ delete rest.kind;
642
+ files[i] = { upload: `sha256:${hit.src.sha256}`, kind: hit.src.kind, ...rest };
643
+ }
644
+ build.files = files;
645
+ doc.build = build;
646
+ }
647
+ }
648
+ finally {
649
+ await tmp.cleanup();
650
+ }
651
+ const sent = doc;
652
+ const org = opts.organizationId ?? (await this.#organizationId());
653
+ let created = await this.builds.create(org, {
654
+ templateSlug: opts.templateSlug,
655
+ recipe: sent,
656
+ ...(opts.displayName !== undefined ? { displayName: opts.displayName } : {}),
657
+ ...(opts.description !== undefined ? { description: opts.description } : {}),
658
+ ...(opts.autoPublish !== undefined ? { autoPublish: opts.autoPublish } : {}),
659
+ ...(opts.acknowledgedScanFindings !== undefined ? { acknowledgedScanFindings: opts.acknowledgedScanFindings } : {}),
660
+ ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
661
+ });
662
+ emit({ type: 'build', build: created });
663
+ if (opts.wait) {
664
+ const waitOpts = opts.wait === true ? {} : { ...opts.wait };
665
+ if (opts.signal && !waitOpts.signal)
666
+ waitOpts.signal = opts.signal;
667
+ const outer = waitOpts.onChange;
668
+ waitOpts.onChange = (b) => {
669
+ if (b.state !== created.state || b.registration.state !== created.registration.state)
670
+ emit({ type: 'build', build: b });
671
+ outer?.(b);
672
+ };
673
+ created = await this.builds.waitForBuild(org, created.id, waitOpts);
674
+ }
675
+ return { build: created, recipe: sent, uploads };
257
676
  }
258
677
  /** Templates the API key's organization can use (platform + its own), with the version `open` picks. */
259
678
  async list(params = {}) {
package/dist/tools.d.ts CHANGED
@@ -1,15 +1,3 @@
1
- /**
2
- * Framework-neutral agent tools for a workspace (TEMPLATES.md):
3
- *
4
- * await agent.run({ input, tools: workspaceTools(workspace) });
5
- *
6
- * Each tool is { name, description, parameters (JSON Schema 2020-12), execute }.
7
- * `execute` validates its arguments against `parameters` (the same schema the
8
- * model saw), then calls the cell gateway through the workspace's managed tool
9
- * token. The customer's model and agent loop stay in the customer's
10
- * application; toOpenAITools/toAnthropicTools export the definitions in those
11
- * providers' formats and executeToolCall dispatches a model's tool call.
12
- */
13
1
  import type { CellClientOptions } from './cell.js';
14
2
  import type { ToolName } from './tokens.js';
15
3
  import type { Workspace } from './workspace.js';
@@ -37,8 +25,10 @@ export interface WorkspaceTool<A extends Record<string, unknown> = Record<string
37
25
  };
38
26
  /** Which workspace tool permission the call needs (exec, files, pty, process, git, browser). */
39
27
  permission: ToolName;
28
+ /** `toolCallId` (0.7.0+): the model's call id, recorded by tool-call capture (executeToolCall passes it). */
40
29
  execute(args: A, options?: {
41
30
  signal?: AbortSignal;
31
+ toolCallId?: string;
42
32
  }): Promise<R>;
43
33
  }
44
34
  export declare class ToolArgumentError extends Error {
@@ -97,12 +87,15 @@ export declare function toAnthropicTools(tools: readonly WorkspaceTool[]): {
97
87
  }[];
98
88
  /**
99
89
  * Runs a model's tool call: `arguments` may be the JSON string (OpenAI) or an object (Anthropic
100
- * `input`). Throws for unknown tools and invalid arguments (ToolArgumentError).
90
+ * `input`). Throws for unknown tools and invalid arguments (ToolArgumentError). The call's `id` / `call_id` is passed
91
+ * to `execute` as `toolCallId` (0.7.0+), so `executeToolCall(capture.tools(tools), call)` records it.
101
92
  */
102
93
  export declare function executeToolCall(tools: readonly WorkspaceTool[], call: {
103
94
  name: string;
104
95
  arguments?: string | Record<string, unknown>;
105
96
  input?: Record<string, unknown>;
97
+ id?: string;
98
+ call_id?: string;
106
99
  }, options?: {
107
100
  signal?: AbortSignal;
108
101
  }): Promise<unknown>;
package/dist/tools.js CHANGED
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Framework-neutral agent tools for a workspace (TEMPLATES.md):
3
+ *
4
+ * await agent.run({ input, tools: workspaceTools(workspace) });
5
+ *
6
+ * Each tool is { name, description, parameters (JSON Schema 2020-12), execute }.
7
+ * `execute` validates its arguments against `parameters` (the same schema the
8
+ * model saw), then calls the cell gateway through the workspace's managed tool
9
+ * token. The customer's model and agent loop stay in the customer's
10
+ * application; toOpenAITools/toAnthropicTools export the definitions in those
11
+ * providers' formats and executeToolCall dispatches a model's tool call.
12
+ */
13
+ import { CAPTURE_BARRIER } from "./cell.js";
1
14
  export class ToolArgumentError extends Error {
2
15
  tool;
3
16
  issues;
@@ -283,7 +296,12 @@ export function workspaceTools(workspace, opts = {}) {
283
296
  const issues = validateArgs(d.parameters, args);
284
297
  if (issues.length > 0)
285
298
  throw new ToolArgumentError(`${prefix}${d.name}`, issues);
286
- return d.run(args, options);
299
+ // Read-your-writes: tool calls captured before this one are in the workspace before it runs (bounded).
300
+ const barrier = workspace[CAPTURE_BARRIER];
301
+ const pending = typeof barrier === 'function' ? barrier.call(workspace) : undefined;
302
+ if (pending)
303
+ await pending;
304
+ return d.run(args, options.signal ? { signal: options.signal } : {});
287
305
  },
288
306
  }));
289
307
  }
@@ -299,7 +317,8 @@ export function toAnthropicTools(tools) {
299
317
  }
300
318
  /**
301
319
  * Runs a model's tool call: `arguments` may be the JSON string (OpenAI) or an object (Anthropic
302
- * `input`). Throws for unknown tools and invalid arguments (ToolArgumentError).
320
+ * `input`). Throws for unknown tools and invalid arguments (ToolArgumentError). The call's `id` / `call_id` is passed
321
+ * to `execute` as `toolCallId` (0.7.0+), so `executeToolCall(capture.tools(tools), call)` records it.
303
322
  */
304
323
  export async function executeToolCall(tools, call, options = {}) {
305
324
  const tool = tools.find((t) => t.name === call.name);
@@ -313,5 +332,6 @@ export async function executeToolCall(tools, call, options = {}) {
313
332
  catch {
314
333
  throw new ToolArgumentError(call.name, ['arguments are not valid JSON']);
315
334
  }
316
- return tool.execute(args, options);
335
+ const toolCallId = call.call_id ?? call.id;
336
+ return tool.execute(args, toolCallId === undefined ? options : { ...options, toolCallId });
317
337
  }