@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/CHANGELOG.md +105 -1
- package/README.md +216 -11
- package/dist/capture-adapters.d.ts +178 -0
- package/dist/capture-adapters.js +432 -0
- package/dist/capture-serialize.d.ts +97 -0
- package/dist/capture-serialize.js +481 -0
- package/dist/capture-text.d.ts +14 -0
- package/dist/capture-text.js +13 -0
- package/dist/capture.d.ts +288 -0
- package/dist/capture.js +1326 -0
- package/dist/cell.d.ts +7 -0
- package/dist/cell.js +13 -0
- package/dist/client.d.ts +24 -2
- package/dist/client.js +24 -5
- package/dist/egress.d.ts +18 -0
- package/dist/errors.d.ts +21 -2
- package/dist/errors.js +19 -2
- package/dist/generated/app-api.d.ts +2900 -193
- package/dist/http.d.ts +1 -1
- package/dist/http.js +1 -1
- package/dist/index.d.ts +10 -3
- package/dist/index.js +4 -1
- package/dist/lifecycle.d.ts +5 -2
- package/dist/lifecycle.js +5 -1
- package/dist/progress.d.ts +17 -3
- package/dist/progress.js +18 -4
- package/dist/tar.d.ts +40 -0
- package/dist/tar.js +150 -0
- package/dist/template-file.d.ts +92 -0
- package/dist/template-file.js +326 -0
- package/dist/templates.d.ts +318 -9
- package/dist/templates.js +432 -13
- package/dist/tools.d.ts +6 -13
- package/dist/tools.js +23 -3
- package/dist/workspace.d.ts +28 -3
- package/dist/workspace.js +52 -3
- package/package.json +24 -2
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
|
-
|
|
61
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
335
|
+
const toolCallId = call.call_id ?? call.id;
|
|
336
|
+
return tool.execute(args, toolCallId === undefined ? options : { ...options, toolCallId });
|
|
317
337
|
}
|