@shardflux/sdk 0.5.0 → 0.6.1

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.
@@ -1,12 +1,47 @@
1
1
  /**
2
- * Template registry and custom template builds over the application API (/v1).
3
- * Types are written by hand.
2
+ * Template registry, custom template builds, the version file tree and diff, and template dev mode (drafts and test
3
+ * instances) over the application API (/v1). Registry and build types are written by hand and checked against the
4
+ * generated contract in type-checks.ts; the Templates v2 types (contracts §19) alias the generated schemas.
4
5
  *
5
6
  * Publishing/archiving an organization template version is a browser-only
6
7
  * owner/admin action (it changes what `open` resolves for every project), so
7
8
  * it is not exposed here.
8
9
  */
9
- import type { ClientContext, Page } from './client.js';
10
+ import type { components } from './generated/app-api.js';
11
+ import type { ClientContext, Operation, Page, WaitOptions } from './client.js';
12
+ import type { ToolName } from './tokens.js';
13
+ import { Workspace } from './workspace.js';
14
+ type S = components['schemas'];
15
+ /** Manifest v2 `defaults` of a version (contracts §19.7). */
16
+ export type TemplateDefaults = S['TemplateDefaults'];
17
+ /** How a version was produced: a recipe build, a saved workspace (or draft publish), or git (reserved). */
18
+ export type TemplateSource = S['TemplateSource'];
19
+ /** The version's file list state (the tree and diff routes read it). */
20
+ export type TemplateFilesSummary = S['TemplateFilesSummary'];
21
+ /** Storage of a template version (contracts §19.13). */
22
+ export type TemplateStorage = S['TemplateStorage'];
23
+ export type TemplateStorageWarning = S['TemplateStorageWarning'];
24
+ /** Template storage of an organization (counts toward retained_state_gib). */
25
+ export type OrgTemplateStorage = S['OrgTemplateStorage'];
26
+ /** One inode path of a template version (contracts §19.10). */
27
+ export type TemplateFileEntry = S['TemplateFileEntry'];
28
+ /** One directory level of a version's tree (keyset-paginated). */
29
+ export type TemplateFilePage = S['TemplateFilePage'];
30
+ /** One path of a version diff: added | removed | changed | type_changed | metadata. */
31
+ export type TemplateDiffEntry = S['TemplateDiffEntry'];
32
+ export type TemplateDiffChange = TemplateDiffEntry['change'];
33
+ /** One page of a version diff; the first page (no cursor) carries `summary`. */
34
+ export type TemplateDiffPage = S['TemplateDiffPage'];
35
+ /** The live draft of an organization template (contracts §19.9). */
36
+ export type TemplateDraft = S['TemplateDraft'];
37
+ /** A disk-only capture of the draft (a test instance or a publish starts from one). */
38
+ export type DraftState = S['DraftState'];
39
+ export type CreateDraftBody = S['CreateDraftBody'];
40
+ export type CreateTestInstanceBody = S['CreateTestInstanceBody'];
41
+ export type PublishDraftBody = S['PublishDraftBody'];
42
+ export type SaveAsTemplateBody = S['SaveAsTemplateBody'];
43
+ /** Defaults of a version produced by save-as-template or a draft publish (omitted fields: persistent, platform idle timeout, no limits). */
44
+ export type TemplateDefaultsInput = NonNullable<SaveAsTemplateBody['defaults']>;
10
45
  export type TemplateOwner = 'platform' | 'organization';
11
46
  export type TemplateVersionState = 'unpublished' | 'published' | 'archived';
12
47
  export type CeilingSource = 'template' | 'user' | 'plan';
@@ -76,6 +111,42 @@ export interface TemplateVersion {
76
111
  }>;
77
112
  build_id: string | null;
78
113
  publishable: boolean;
114
+ /** `shardflux.template.v1` or `shardflux.template.v2` (Templates v2). */
115
+ manifest_schema: string | null;
116
+ /** Layouts a new workspace of this version may use (a manifest without the field: legacy only). */
117
+ disk_layouts: Array<'legacy' | 'layered'>;
118
+ source: TemplateSource | null;
119
+ /** The platform base (or parent) the version was built on. */
120
+ base: {
121
+ name: string;
122
+ version: string;
123
+ manifest_sha256: string | null;
124
+ rootfs_sha256: string | null;
125
+ } | null;
126
+ defaults: TemplateDefaults;
127
+ /** The file list the tree and diff read (`loaded` when files() and diff() can answer). */
128
+ files: TemplateFilesSummary;
129
+ rootfs_bytes: number | null;
130
+ /** The org layers of this version's chain, bottom to top (empty for images). */
131
+ chain: Array<{
132
+ layer_id: string;
133
+ bytes: number;
134
+ introduced_in_version: number | null;
135
+ }>;
136
+ storage: TemplateStorage;
137
+ }
138
+ /** Summary of an organization template's live draft on the template view (null when none). */
139
+ export interface TemplateDraftSummary {
140
+ workspace_id: string;
141
+ workspace_key: string;
142
+ observed_state: string;
143
+ base_version: {
144
+ id: string;
145
+ template_id: string;
146
+ slug: string;
147
+ version: number;
148
+ };
149
+ created_at: string;
79
150
  }
80
151
  export interface TemplateSummary {
81
152
  id: string;
@@ -86,7 +157,16 @@ export interface TemplateSummary {
86
157
  created_at: string;
87
158
  archived_at: string | null;
88
159
  shadowed_by_organization_template: boolean;
160
+ /** Mutable template-level defaults (contracts §20.5, §20.7): the size of a start without caps.memory_mib, and the idle policy of a workspace that sets none. */
161
+ defaults: {
162
+ memory_mib: number | null;
163
+ idle_policy: string | null;
164
+ };
89
165
  open_version: TemplateVersion | null;
166
+ /** The live draft (organization templates in dev mode), or null. */
167
+ draft: TemplateDraftSummary | null;
168
+ /** Reserved (T2): always `pinned` in T1. */
169
+ update_policy: 'pinned' | 'auto';
90
170
  }
91
171
  export interface TemplateDetail extends TemplateSummary {
92
172
  plan: {
@@ -179,14 +259,15 @@ export interface TemplateBuild {
179
259
  mode: 'none' | 'egress_allowlist';
180
260
  allow_hosts: string[];
181
261
  };
262
+ /** recipe_schema/recipe_sha256 are null for workspace-source builds (save-as-template, draft publish). */
182
263
  provenance: {
183
- recipe_schema: string;
184
- recipe_sha256: string;
264
+ recipe_schema: string | null;
265
+ recipe_sha256: string | null;
185
266
  base_artifact_sha256: string;
186
267
  builder_id: string | null;
187
268
  attempt: number;
188
269
  };
189
- /** Present on create and get of one build. */
270
+ /** Recipe builds only, on create and get of one build. */
190
271
  recipe?: {
191
272
  dockerfile: string;
192
273
  };
@@ -220,6 +301,20 @@ export interface TemplateBuild {
220
301
  };
221
302
  publishable: boolean;
222
303
  builder_availability: BuilderAvailability;
304
+ /** `recipe` (Dockerfile dialect) or `workspace` (save-as-template, draft publish; contracts §19.8). */
305
+ source_kind: 'recipe' | 'workspace';
306
+ source_workspace_id: string | null;
307
+ /** The captured checkpoint (null until the capture operation succeeded). */
308
+ source_checkpoint_id: string | null;
309
+ /** sf-scrub.v1 report of a workspace build: {policy, removed_count, removed_bytes, removed_paths, emptied, reimposed}. */
310
+ scrub_result: Record<string, unknown> | null;
311
+ /** The produced file list: {key, size, sha256, content_sha256, entries, total_file_bytes}. */
312
+ files: Record<string, unknown> | null;
313
+ produced_layer_id: string | null;
314
+ /** The base chain already had 4 org layers and was squashed into the produced one. */
315
+ squashed: boolean | null;
316
+ /** Organization bytes of the produced chain (limit: the plan's template_org_bytes_max). */
317
+ org_bytes: number | null;
223
318
  }
224
319
  export interface TemplateRecipe {
225
320
  /** `<template slug>@<version>`, referenced in the Dockerfile as `FROM shardflux-base`. */
@@ -258,8 +353,11 @@ export interface TemplateBuildLogUrl {
258
353
  }
259
354
  export interface WaitForBuildOptions {
260
355
  timeoutMs?: number;
356
+ /** Backoff used when the server does not hold the poll (default 1 s doubling to 10 s). */
261
357
  pollIntervalMs?: number;
262
358
  maxPollIntervalMs?: number;
359
+ /** Ask the server to hold each poll until the build changes (`Prefer: wait`, contracts §3; default true). */
360
+ serverWait?: boolean;
263
361
  signal?: AbortSignal;
264
362
  }
265
363
  /** Finished from the caller's point of view: failed/canceled/legacy succeeded, or published and its registration settled. */
@@ -269,6 +367,157 @@ export declare class TemplateBuildTimeoutError extends Error {
269
367
  readonly build: TemplateBuild;
270
368
  constructor(build: TemplateBuild, waitedMs: number);
271
369
  }
370
+ /** operation: the capture (layer_snapshot) of a running workspace, null otherwise. build: poll it until registration.state is registered. */
371
+ export interface SaveAsTemplateResponse {
372
+ operation: Operation | null;
373
+ build: TemplateBuild;
374
+ }
375
+ /** Save-as-template (contracts §19.8): everything in the workspace, minus the sf-scrub.v1 list, becomes one new org layer. */
376
+ export interface SaveAsTemplateParams {
377
+ /** Organization template to save into (created when absent; platform slugs are refused with platform_template_slug). */
378
+ templateSlug: string;
379
+ /** Template name when this save creates the template. */
380
+ displayName?: string;
381
+ /** The version description (default: the source version's). */
382
+ description?: string;
383
+ /** Defaults of the new version (default: the source version's, else persistent). */
384
+ defaults?: TemplateDefaultsInput;
385
+ /** A committed checkpoint of this workspace to save instead of its current state. */
386
+ checkpointId?: string;
387
+ /** Publish the produced version once registered (default true). */
388
+ autoPublish?: boolean;
389
+ /** Up to 200 absolute paths the credential scan may report without failing the build. */
390
+ acknowledgedScanFindings?: string[];
391
+ /** Defaults to a random key so retries never start a second save. */
392
+ idempotencyKey?: string;
393
+ }
394
+ /** The JSON body of save-as-template (and, without template_slug/display_name/checkpoint_id, of a draft publish). */
395
+ export declare function saveAsTemplateBody(params: SaveAsTemplateParams): SaveAsTemplateBody;
396
+ export interface TemplateFilesParams {
397
+ /** Directory to list (absolute, normalized; default `/`). */
398
+ path?: string;
399
+ /** 1..1000 (default 200). */
400
+ limit?: number;
401
+ cursor?: string;
402
+ /** Pick the platform template even when an organization template shadows its slug. */
403
+ owner?: TemplateOwner;
404
+ /** Use the organization form of the route (default: the API key's organization). */
405
+ organizationId?: string;
406
+ }
407
+ export interface TemplateDiffParams {
408
+ /** A version number of this template, or `base` (the `to` version's build base, possibly a platform version). */
409
+ from: number | 'base';
410
+ to: number;
411
+ /** Only paths starting with this (absolute) prefix. */
412
+ pathPrefix?: string;
413
+ change?: TemplateDiffChange;
414
+ /** 1..1000. */
415
+ limit?: number;
416
+ cursor?: string;
417
+ owner?: TemplateOwner;
418
+ organizationId?: string;
419
+ }
420
+ export interface CreateDraftParams {
421
+ /** `<slug>@<version>`: a published, layered-capable version (default: the template's latest published version; required when it has none). */
422
+ base?: string;
423
+ caps?: {
424
+ cpu_millis?: number;
425
+ memory_mib?: number;
426
+ disk_gib?: number;
427
+ };
428
+ /** Browser/session principals only; API keys use their own project. */
429
+ projectId?: string;
430
+ /** Attribution label for tool tokens obtained through the returned workspace object. */
431
+ agentLabel?: string;
432
+ tools?: ToolName[];
433
+ /** `false`: return at once (possibly not ready). Default: wait until the draft is ready. */
434
+ wait?: false | WaitOptions;
435
+ idempotencyKey?: string;
436
+ }
437
+ export interface OpenTestInstanceParams {
438
+ /** A draft state (states()). Omitted: a fresh capture of the running draft (its current checkpoint when suspended). */
439
+ stateId?: string;
440
+ /** Workspace key (default sf:test:<slug>:<8 hex>); keys starting with `sf:` are reserved. */
441
+ key?: string;
442
+ caps?: {
443
+ cpu_millis?: number;
444
+ memory_mib?: number;
445
+ disk_gib?: number;
446
+ };
447
+ agentLabel?: string;
448
+ tools?: ToolName[];
449
+ /** `false`: return at once. Default: wait until the test instance is ready. */
450
+ wait?: false | WaitOptions;
451
+ idempotencyKey?: string;
452
+ }
453
+ export interface PublishDraftParams {
454
+ /** A draft state to publish (default: the draft's current state; a fresh capture when running). */
455
+ stateId?: string;
456
+ description?: string;
457
+ defaults?: TemplateDefaultsInput;
458
+ autoPublish?: boolean;
459
+ acknowledgedScanFindings?: string[];
460
+ idempotencyKey?: string;
461
+ }
462
+ /** The draft's workspace (a Workspace handle), the draft view and the open operation (null when it was already ready). */
463
+ export interface DraftOpened {
464
+ workspace: Workspace;
465
+ draft: TemplateDraft;
466
+ operation: Operation | null;
467
+ }
468
+ /**
469
+ * Template dev mode for one organization template (contracts §19.9): the single live draft (a layered, persistent
470
+ * workspace on the draft base), its captured states, disposable test instances (session workspaces on a copy of a
471
+ * state) and publishing the draft as the next version. Mutations need build access (owners/admins, API keys with a
472
+ * tool permission); others get 403 forbidden with details.reason template_dev_mode_role.
473
+ */
474
+ export declare class TemplateDraftApi {
475
+ #private;
476
+ readonly slug: string;
477
+ constructor(ctx: () => ClientContext, slug: string, organizationId?: string);
478
+ /**
479
+ * Opens the template's draft on `base` (202; waits until ready unless `wait: false`). 409 template_not_layered (the
480
+ * base is not layered-capable, or layered opens are off), 409 draft_exists (details.workspace_id).
481
+ */
482
+ create(params?: CreateDraftParams): Promise<DraftOpened>;
483
+ /** The draft (404 not_found with details.reason draft_not_found when the template has none). */
484
+ get(): Promise<TemplateDraft>;
485
+ /** Like get() but null when the template has no draft. */
486
+ find(): Promise<TemplateDraft | null>;
487
+ /** Deletes the draft (operation `delete`, input.reason draft_discarded) and ends its live test instances. */
488
+ discard(opts?: {
489
+ idempotencyKey?: string;
490
+ }): Promise<Operation>;
491
+ /** Captures a draft state (`layer_snapshot`; its result carries checkpoint_id). A suspended draft is 409 workspace_not_running. */
492
+ captureState(opts?: {
493
+ label?: string;
494
+ idempotencyKey?: string;
495
+ }): Promise<Operation>;
496
+ /** The draft's states, newest first. */
497
+ states(params?: {
498
+ limit?: number;
499
+ cursor?: string;
500
+ }): Promise<Page<DraftState>>;
501
+ statesAll(params?: {
502
+ limit?: number;
503
+ }): AsyncGenerator<DraftState>;
504
+ /**
505
+ * Opens a test instance: a session workspace on the draft base whose layer is a copy of the state (its writes never
506
+ * reach the draft). It ends on close(), idle or a draft discard. Waits until ready unless `wait: false`.
507
+ */
508
+ openTestInstance(params?: OpenTestInstanceParams): Promise<Workspace>;
509
+ /** The draft's live test instances (`includeEnded` adds ended ones: tombstones with ended_reason). */
510
+ testInstances(params?: {
511
+ limit?: number;
512
+ cursor?: string;
513
+ includeEnded?: boolean;
514
+ }): Promise<Page<Workspace>>;
515
+ /**
516
+ * Publishes the draft as the template's next version (save-as-template from the draft). 409 draft_stale (details
517
+ * latest_version, draft_base_version) or build_in_progress. Poll the returned build (templates.builds.waitForBuild).
518
+ */
519
+ publish(params?: PublishDraftParams): Promise<SaveAsTemplateResponse>;
520
+ }
272
521
  export declare class TemplateBuildsApi {
273
522
  #private;
274
523
  constructor(ctx: () => ClientContext);
@@ -290,7 +539,8 @@ export declare class TemplateBuildsApi {
290
539
  cancel(organizationId: string, buildId: string): Promise<TemplateBuild>;
291
540
  builderAvailability(organizationId: string): Promise<BuilderAvailability>;
292
541
  /**
293
- * Polls until the build is settled and returns it: failed | canceled, or published with its registration
542
+ * Waits until the build is settled (each poll asks the server to hold it until the build changes, `Prefer: wait`,
543
+ * falling back to backoff polling on servers that do not wait) and returns it: failed | canceled, or published with its registration
294
544
  * `registered` (the version exists; `template_version`) or `failed`. Legacy builds end `succeeded`.
295
545
  * Throws TemplateBuildTimeoutError at the deadline (default 30 min); the build continues.
296
546
  */
@@ -325,4 +575,26 @@ export declare class TemplatesApi {
325
575
  includeArchived?: boolean;
326
576
  owner?: TemplateOwner;
327
577
  }): Promise<TemplateDetail | null>;
578
+ /**
579
+ * One directory level of a version's file tree (contracts §19.10), sorted by name bytes. 409 conflict with
580
+ * details.reason file_list_unavailable (no file list) or file_list_indexing (retryable); 404 path_not_found or
581
+ * version_not_found; 422 invalid_path. File contents are not served: open a draft or test instance for that.
582
+ */
583
+ files(slug: string, version: number, params?: TemplateFilesParams): Promise<TemplateFilePage>;
584
+ /** Every entry of one directory, following next_cursor. */
585
+ filesAll(slug: string, version: number, params?: Omit<TemplateFilesParams, 'cursor'>): AsyncGenerator<TemplateFileEntry>;
586
+ /** One entry of a version's file tree (404 path_not_found). */
587
+ fileEntry(slug: string, version: number, path: string, params?: {
588
+ owner?: TemplateOwner;
589
+ organizationId?: string;
590
+ }): Promise<TemplateFileEntry>;
591
+ /** One page of the diff between two versions (keyset-paginated by path; the first page carries `summary`). */
592
+ diff(slug: string, params: TemplateDiffParams): Promise<TemplateDiffPage>;
593
+ /** Every diff entry, following next_cursor. */
594
+ diffAll(slug: string, params: Omit<TemplateDiffParams, 'cursor'>): AsyncGenerator<TemplateDiffEntry>;
595
+ /** Dev mode of one organization template: its draft, states, test instances and publish (contracts §19.9). */
596
+ draft(slug: string, params?: {
597
+ organizationId?: string;
598
+ }): TemplateDraftApi;
328
599
  }
600
+ export {};
package/dist/templates.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ShardfluxApiError } from "./errors.js";
2
- import { randomId } from "./http.js";
2
+ import { pollWithWait, randomId } from "./http.js";
3
+ import { Workspace } from "./workspace.js";
3
4
  const TERMINAL = new Set(['succeeded', 'failed', 'canceled']);
4
5
  /** Finished from the caller's point of view: failed/canceled/legacy succeeded, or published and its registration settled. */
5
6
  export function buildSettled(build) {
@@ -17,6 +18,164 @@ export class TemplateBuildTimeoutError extends Error {
17
18
  }
18
19
  }
19
20
  const enc = encodeURIComponent;
21
+ /** Where a template route lives: the SDK form `/v1/templates/{slug}` or the organization form (both take API keys). */
22
+ function templatePath(slug, organizationId) {
23
+ return organizationId === undefined ? `/v1/templates/${enc(slug)}` : `/v1/organizations/${enc(organizationId)}/templates/${enc(slug)}`;
24
+ }
25
+ /** The JSON body of save-as-template (and, without template_slug/display_name/checkpoint_id, of a draft publish). */
26
+ export function saveAsTemplateBody(params) {
27
+ const body = { template_slug: params.templateSlug };
28
+ if (params.displayName !== undefined)
29
+ body.display_name = params.displayName;
30
+ if (params.description !== undefined)
31
+ body.description = params.description;
32
+ if (params.defaults !== undefined)
33
+ body.defaults = params.defaults;
34
+ if (params.checkpointId !== undefined)
35
+ body.checkpoint_id = params.checkpointId;
36
+ if (params.autoPublish !== undefined)
37
+ body.auto_publish = params.autoPublish;
38
+ if (params.acknowledgedScanFindings !== undefined)
39
+ body.acknowledged_scan_findings = params.acknowledgedScanFindings;
40
+ return body;
41
+ }
42
+ /**
43
+ * Template dev mode for one organization template (contracts §19.9): the single live draft (a layered, persistent
44
+ * workspace on the draft base), its captured states, disposable test instances (session workspaces on a copy of a
45
+ * state) and publishing the draft as the next version. Mutations need build access (owners/admins, API keys with a
46
+ * tool permission); others get 403 forbidden with details.reason template_dev_mode_role.
47
+ */
48
+ export class TemplateDraftApi {
49
+ slug;
50
+ #ctx;
51
+ #org;
52
+ constructor(ctx, slug, organizationId) {
53
+ this.#ctx = ctx;
54
+ this.slug = slug;
55
+ this.#org = organizationId;
56
+ }
57
+ #path(suffix = '') {
58
+ return `${templatePath(this.slug, this.#org)}/draft${suffix}`;
59
+ }
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 };
69
+ }
70
+ /**
71
+ * Opens the template's draft on `base` (202; waits until ready unless `wait: false`). 409 template_not_layered (the
72
+ * base is not layered-capable, or layered opens are off), 409 draft_exists (details.workspace_id).
73
+ */
74
+ async create(params = {}) {
75
+ const body = {};
76
+ if (params.base !== undefined)
77
+ body.base = params.base;
78
+ if (params.caps !== undefined)
79
+ body.caps = params.caps;
80
+ if (params.projectId !== undefined)
81
+ body.project_id = params.projectId;
82
+ if (params.agentLabel !== undefined)
83
+ body.agent_label = params.agentLabel;
84
+ if (params.tools !== undefined)
85
+ body.tools = params.tools;
86
+ const ctx = this.#ctx();
87
+ const res = await ctx.http.jsonWithStatus('POST', this.#path(), { json: body, idempotencyKey: params.idempotencyKey ?? randomId('draft-') }, ctx.authorization);
88
+ const { workspace, operation } = await this.#opened(res, params);
89
+ return { workspace, draft: { ...res.body.draft, workspace: workspace.data }, operation };
90
+ }
91
+ /** The draft (404 not_found with details.reason draft_not_found when the template has none). */
92
+ get() {
93
+ const ctx = this.#ctx();
94
+ return ctx.http.json('GET', this.#path(), {}, ctx.authorization);
95
+ }
96
+ /** Like get() but null when the template has no draft. */
97
+ async find() {
98
+ try {
99
+ return await this.get();
100
+ }
101
+ catch (err) {
102
+ if (err instanceof ShardfluxApiError && err.status === 404 && err.details?.reason === 'draft_not_found')
103
+ return null;
104
+ throw err;
105
+ }
106
+ }
107
+ /** Deletes the draft (operation `delete`, input.reason draft_discarded) and ends its live test instances. */
108
+ async discard(opts = {}) {
109
+ const ctx = this.#ctx();
110
+ const out = await ctx.http.json('DELETE', this.#path(), { idempotencyKey: opts.idempotencyKey ?? randomId('op-') }, ctx.authorization);
111
+ return out.operation;
112
+ }
113
+ /** Captures a draft state (`layer_snapshot`; its result carries checkpoint_id). A suspended draft is 409 workspace_not_running. */
114
+ async captureState(opts = {}) {
115
+ const ctx = this.#ctx();
116
+ const out = await ctx.http.json('POST', this.#path('/states'), { json: opts.label === undefined ? {} : { label: opts.label }, idempotencyKey: opts.idempotencyKey ?? randomId('op-') }, ctx.authorization);
117
+ return out.operation;
118
+ }
119
+ /** The draft's states, newest first. */
120
+ async states(params = {}) {
121
+ const ctx = this.#ctx();
122
+ const page = await ctx.http.json('GET', this.#path('/states'), { query: { limit: params.limit, cursor: params.cursor } }, ctx.authorization);
123
+ return { data: page.data, nextCursor: page.next_cursor };
124
+ }
125
+ async *statesAll(params = {}) {
126
+ let cursor;
127
+ do {
128
+ const page = await this.states({ ...params, ...(cursor === undefined ? {} : { cursor }) });
129
+ yield* page.data;
130
+ cursor = page.nextCursor ?? undefined;
131
+ } while (cursor !== undefined);
132
+ }
133
+ /**
134
+ * Opens a test instance: a session workspace on the draft base whose layer is a copy of the state (its writes never
135
+ * reach the draft). It ends on close(), idle or a draft discard. Waits until ready unless `wait: false`.
136
+ */
137
+ async openTestInstance(params = {}) {
138
+ const body = {};
139
+ if (params.stateId !== undefined)
140
+ body.state_id = params.stateId;
141
+ if (params.key !== undefined)
142
+ body.key = params.key;
143
+ if (params.caps !== undefined)
144
+ body.caps = params.caps;
145
+ if (params.agentLabel !== undefined)
146
+ body.agent_label = params.agentLabel;
147
+ if (params.tools !== undefined)
148
+ body.tools = params.tools;
149
+ const ctx = this.#ctx();
150
+ const res = await ctx.http.jsonWithStatus('POST', this.#path('/test-instances'), { json: body, idempotencyKey: params.idempotencyKey ?? randomId('test-') }, ctx.authorization);
151
+ return (await this.#opened(res, params)).workspace;
152
+ }
153
+ /** The draft's live test instances (`includeEnded` adds ended ones: tombstones with ended_reason). */
154
+ async testInstances(params = {}) {
155
+ const ctx = this.#ctx();
156
+ const page = await ctx.http.json('GET', this.#path('/test-instances'), { query: { limit: params.limit, cursor: params.cursor, include_ended: params.includeEnded } }, ctx.authorization);
157
+ return { data: page.data.map((v) => new Workspace(ctx, v)), nextCursor: page.next_cursor };
158
+ }
159
+ /**
160
+ * Publishes the draft as the template's next version (save-as-template from the draft). 409 draft_stale (details
161
+ * latest_version, draft_base_version) or build_in_progress. Poll the returned build (templates.builds.waitForBuild).
162
+ */
163
+ publish(params = {}) {
164
+ const body = {};
165
+ if (params.stateId !== undefined)
166
+ body.state_id = params.stateId;
167
+ if (params.description !== undefined)
168
+ body.description = params.description;
169
+ if (params.defaults !== undefined)
170
+ body.defaults = params.defaults;
171
+ if (params.autoPublish !== undefined)
172
+ body.auto_publish = params.autoPublish;
173
+ if (params.acknowledgedScanFindings !== undefined)
174
+ body.acknowledged_scan_findings = params.acknowledgedScanFindings;
175
+ const ctx = this.#ctx();
176
+ return ctx.http.json('POST', this.#path('/publish'), { json: body, idempotencyKey: params.idempotencyKey ?? randomId('publish-') }, ctx.authorization);
177
+ }
178
+ }
20
179
  export class TemplateBuildsApi {
21
180
  #ctx;
22
181
  constructor(ctx) {
@@ -53,23 +212,35 @@ export class TemplateBuildsApi {
53
212
  return this.#ctx().http.json('GET', `/v1/organizations/${enc(organizationId)}/template-builder-availability`, {}, this.#ctx().authorization);
54
213
  }
55
214
  /**
56
- * Polls until the build is settled and returns it: failed | canceled, or published with its registration
215
+ * Waits until the build is settled (each poll asks the server to hold it until the build changes, `Prefer: wait`,
216
+ * falling back to backoff polling on servers that do not wait) and returns it: failed | canceled, or published with its registration
57
217
  * `registered` (the version exists; `template_version`) or `failed`. Legacy builds end `succeeded`.
58
218
  * Throws TemplateBuildTimeoutError at the deadline (default 30 min); the build continues.
59
219
  */
60
220
  async waitForBuild(organizationId, buildId, opts = {}) {
61
- const { sleep } = this.#ctx();
221
+ const { sleep, http, authorization } = this.#ctx();
62
222
  const timeoutMs = opts.timeoutMs ?? 1_800_000;
63
223
  const maxInterval = opts.maxPollIntervalMs ?? 10_000;
64
224
  let interval = opts.pollIntervalMs ?? 1_000;
65
225
  const started = Date.now();
226
+ let lastKey = null;
66
227
  for (;;) {
67
- const build = await this.get(organizationId, buildId);
228
+ if (opts.signal?.aborted)
229
+ throw opts.signal.reason instanceof Error ? opts.signal.reason : new Error('aborted');
230
+ const waitS = opts.serverWait === false ? 0 : (timeoutMs - (Date.now() - started)) / 1000;
231
+ const t0 = Date.now();
232
+ const { body: build, applied } = await pollWithWait(http, `/v1/organizations/${enc(organizationId)}/template-builds/${enc(buildId)}`, authorization, waitS, opts.signal);
68
233
  if (buildSettled(build))
69
234
  return build;
70
235
  const waited = Date.now() - started;
71
236
  if (waited >= timeoutMs)
72
237
  throw new TemplateBuildTimeoutError(build, waited);
238
+ // 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
+ if (applied && (changed || Date.now() - t0 >= 1_000))
243
+ continue;
73
244
  await sleep(Math.max(10, Math.min(interval, timeoutMs - waited)));
74
245
  if (opts.signal?.aborted)
75
246
  throw opts.signal.reason instanceof Error ? opts.signal.reason : new Error('aborted');
@@ -114,4 +285,45 @@ export class TemplatesApi {
114
285
  throw err;
115
286
  }
116
287
  }
288
+ /**
289
+ * One directory level of a version's file tree (contracts §19.10), sorted by name bytes. 409 conflict with
290
+ * details.reason file_list_unavailable (no file list) or file_list_indexing (retryable); 404 path_not_found or
291
+ * version_not_found; 422 invalid_path. File contents are not served: open a draft or test instance for that.
292
+ */
293
+ async files(slug, version, params = {}) {
294
+ const ctx = this.#ctx();
295
+ return ctx.http.json('GET', `${templatePath(slug, params.organizationId)}/versions/${enc(String(version))}/files`, { query: { path: params.path, limit: params.limit, cursor: params.cursor, owner: params.owner } }, ctx.authorization);
296
+ }
297
+ /** Every entry of one directory, following next_cursor. */
298
+ async *filesAll(slug, version, params = {}) {
299
+ let cursor;
300
+ do {
301
+ const page = await this.files(slug, version, { limit: 1000, ...params, ...(cursor === undefined ? {} : { cursor }) });
302
+ yield* page.data;
303
+ cursor = page.next_cursor ?? undefined;
304
+ } while (cursor !== undefined);
305
+ }
306
+ /** One entry of a version's file tree (404 path_not_found). */
307
+ fileEntry(slug, version, path, params = {}) {
308
+ const ctx = this.#ctx();
309
+ return ctx.http.json('GET', `${templatePath(slug, params.organizationId)}/versions/${enc(String(version))}/files/entry`, { query: { path, owner: params.owner } }, ctx.authorization);
310
+ }
311
+ /** One page of the diff between two versions (keyset-paginated by path; the first page carries `summary`). */
312
+ diff(slug, params) {
313
+ const ctx = this.#ctx();
314
+ return ctx.http.json('GET', `${templatePath(slug, params.organizationId)}/diff`, { query: { from: String(params.from), to: params.to, path_prefix: params.pathPrefix, change: params.change, limit: params.limit, cursor: params.cursor, owner: params.owner } }, ctx.authorization);
315
+ }
316
+ /** Every diff entry, following next_cursor. */
317
+ async *diffAll(slug, params) {
318
+ let cursor;
319
+ do {
320
+ const page = await this.diff(slug, { limit: 1000, ...params, ...(cursor === undefined ? {} : { cursor }) });
321
+ yield* page.data;
322
+ cursor = page.next_cursor ?? undefined;
323
+ } while (cursor !== undefined);
324
+ }
325
+ /** Dev mode of one organization template: its draft, states, test instances and publish (contracts §19.9). */
326
+ draft(slug, params = {}) {
327
+ return new TemplateDraftApi(this.#ctx, slug, params.organizationId);
328
+ }
117
329
  }
package/dist/tokens.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { components } from './generated/app-api.js';
10
10
  import type { HttpClient } from './http.js';
11
+ import type { ProgressListener } from './progress.js';
11
12
  export type ToolToken = components['schemas']['ToolToken'];
12
13
  export type ToolName = ToolToken['tools'][number];
13
14
  export interface ToolTokenOptions {
@@ -28,8 +29,12 @@ export declare class ToolTokenManager {
28
29
  /** Accepts a token obtained elsewhere (e.g. the one returned by a 200 open). */
29
30
  seed(token: ToolToken): void;
30
31
  get current(): ToolToken | undefined;
31
- get(): Promise<ToolToken>;
32
- /** Forces a new token (single-flight). */
33
- refresh(): Promise<ToolToken>;
32
+ /**
33
+ * The current token, or a new one when there is none or it expires within the skew. A fetch is a traced `token` call:
34
+ * `onProgress` sees it (phase `request` with the reason `initial`, `expiring` or `invalidated`) and its timing.
35
+ */
36
+ get(onProgress?: ProgressListener): Promise<ToolToken>;
37
+ /** Forces a new token (single-flight: concurrent callers share one request, and the first caller's trace). */
38
+ refresh(onProgress?: ProgressListener, reason?: string): Promise<ToolToken>;
34
39
  invalidate(): void;
35
40
  }