@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/cell.d.ts CHANGED
@@ -86,6 +86,8 @@ export interface CellClientOptions {
86
86
  * Through Workspace.cell() a wake reports here too (action `wake`).
87
87
  */
88
88
  onProgress?: ProgressListener;
89
+ /** Internal: see CAPTURE_BARRIER. */
90
+ [CAPTURE_BARRIER]?: (() => Promise<unknown> | undefined) | undefined;
89
91
  }
90
92
  /** Per-request transition handling (internal to CellClient). */
91
93
  interface TransitionOptions {
@@ -93,6 +95,11 @@ interface TransitionOptions {
93
95
  wake?: boolean;
94
96
  }
95
97
  export declare const DEFAULT_TRANSITION_TIMEOUT_MS = 120000;
98
+ /**
99
+ * Internal (tool-call capture): a CellClient option whose function is awaited before every request, so calls wait for
100
+ * capture writes recorded before them (read-your-writes). Workspace.cell() sets it; the capture's own client does not.
101
+ */
102
+ export declare const CAPTURE_BARRIER: unique symbol;
96
103
  export interface RunResult {
97
104
  sessionId: string;
98
105
  exitCode: number | null;
package/dist/cell.js CHANGED
@@ -11,6 +11,11 @@ export function cellPath(template, params) {
11
11
  });
12
12
  }
13
13
  export const DEFAULT_TRANSITION_TIMEOUT_MS = 120_000;
14
+ /**
15
+ * Internal (tool-call capture): a CellClient option whose function is awaited before every request, so calls wait for
16
+ * capture writes recorded before them (read-your-writes). Workspace.cell() sets it; the capture's own client does not.
17
+ */
18
+ export const CAPTURE_BARRIER = Symbol('shardflux.captureBarrier');
14
19
  /** Wakes per call at most: a workspace that keeps being suspended again surfaces the refusal. */
15
20
  const MAX_WAKES = 3;
16
21
  const b64 = (bytes) => Buffer.from(typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : bytes).toString('base64');
@@ -72,6 +77,7 @@ export class CellClient {
72
77
  workspaceId;
73
78
  tokens;
74
79
  #opts;
80
+ #barrier;
75
81
  #listener;
76
82
  #wake;
77
83
  #transitionTimeoutMs;
@@ -90,6 +96,7 @@ export class CellClient {
90
96
  this.#wake = opts.wake ?? null;
91
97
  this.#transitionTimeoutMs = opts.transitionTimeoutMs ?? DEFAULT_TRANSITION_TIMEOUT_MS;
92
98
  this.#listener = opts.onProgress;
99
+ this.#barrier = opts[CAPTURE_BARRIER];
93
100
  }
94
101
  #http(endpoint) {
95
102
  let c = this.#clients.get(endpoint);
@@ -120,6 +127,12 @@ export class CellClient {
120
127
  */
121
128
  async request(method, path, init = {}) {
122
129
  const closer = this.#closer.signal;
130
+ if (closer.aborted)
131
+ throw closer.reason;
132
+ // Read-your-writes: tool-call capture writes recorded before this call land first (bounded; never throws).
133
+ const barrier = this.#barrier?.();
134
+ if (barrier)
135
+ await barrier;
123
136
  if (closer.aborted)
124
137
  throw closer.reason;
125
138
  const { wake: wakeAllowed = true, ...reqInit } = init;
package/dist/client.d.ts CHANGED
@@ -21,6 +21,7 @@ import { UsageApi } from './usage.js';
21
21
  import { VolumesApi } from './volumes.js';
22
22
  import type { FinishedOperation, InternalLifecycleOptions, LifecycleOptions, WaitedLifecycleOptions } from './lifecycle.js';
23
23
  import type { ProgressListener } from './progress.js';
24
+ import { CaptureRegistry } from './capture.js';
24
25
  export type WorkspaceView = components['schemas']['Workspace'];
25
26
  export type Operation = components['schemas']['Operation'];
26
27
  /** persistent (kept until deleted) or session (discarded when the session ends: close(), idle timeout; contracts §19.11). */
@@ -34,6 +35,8 @@ export type UpdatePolicy = components['schemas']['UpdatePolicy'];
34
35
  /** Where the workspace's disk came from: null, a fork, or a draft state (test instances). */
35
36
  export type WorkspaceOrigin = components['schemas']['WorkspaceOrigin'];
36
37
  export type ResetWorkspaceBody = components['schemas']['ResetWorkspaceBody'];
38
+ /** GET /v1/workspaces/{id}/inputs (0.7.0): the workspace's text inputs. */
39
+ export type WorkspaceInputs = components['schemas']['WorkspaceInputs'];
37
40
  /** List filter: a lifetime or `any` (the list default is `persistent`). */
38
41
  export type LifetimeFilter = WorkspaceLifetime | 'any';
39
42
  /** List filter: a purpose or `any` (the list default is `standard`). */
@@ -92,7 +95,11 @@ export interface ForkTarget {
92
95
  lifetime?: WorkspaceLifetime;
93
96
  }
94
97
  export interface WaitOptions {
95
- /** Give up waiting after this long (default 300 000 ms); the operation continues server side. */
98
+ /**
99
+ * Give up waiting after this long (default 300 000 ms); the operation continues server side. A start waiting for
100
+ * capacity (`capacity_pending`) does so until its deadline (`error.details.deadline_at`, 15 minutes after it was
101
+ * created) and then fails with `capacity_unavailable` (retryable; nothing was started).
102
+ */
96
103
  timeoutMs?: number;
97
104
  /** First poll delay (default 250 ms); doubles up to `maxPollIntervalMs` with jitter. Used when the server does not wait. */
98
105
  pollIntervalMs?: number;
@@ -122,6 +129,15 @@ export interface OpenParams {
122
129
  * ShardfluxApiError 422 (details.reason `secret_not_available`, details.names) and nothing is created or changed.
123
130
  */
124
131
  secrets?: string[];
132
+ /**
133
+ * The template version's text inputs `{NAME: value}` (0.7.0; contracts §24.3), put into the environment of every
134
+ * exec, terminal, start command and service. A new key stores each given value, else the declared default; an
135
+ * existing key replaces them all (omitted leaves them unchanged). Secret inputs are not passed here: they bind the
136
+ * stored secret of the same name. ShardfluxApiError 422 with details.reason `input_unknown` (an undeclared name),
137
+ * `input_invalid` (a secret input, or a value over 4096 bytes or with CR, LF or NUL) or `input_required`
138
+ * (details.names, details.kind).
139
+ */
140
+ inputs?: Record<string, string>;
125
141
  /**
126
142
  * `session`: the workspace is discarded when its session ends (workspace.close(), or the idle timeout); the key then
127
143
  * opens a NEW workspace. Omitted: the template version's default, else `persistent`. Immutable: reopening a live key
@@ -182,6 +198,8 @@ export interface ClientContext {
182
198
  workspaces: WorkspacesApi;
183
199
  /** The client-level progress listener (ShardfluxOptions.onProgress). */
184
200
  onProgress?: ProgressListener | undefined;
201
+ /** Tool-call captures of this client by workspace id (read-your-writes barrier, discard on delete/reset). */
202
+ captures: CaptureRegistry;
185
203
  }
186
204
  export declare class WorkspacesApi {
187
205
  #private;
@@ -200,7 +218,9 @@ export declare class WorkspacesApi {
200
218
  * that does not wait is polled with bounded exponential backoff (+-20 % jitter). Resolves when the operation
201
219
  * succeeds; throws OperationFailedError when it fails or is canceled, OperationTimeoutError after `timeoutMs` (the
202
220
  * operation keeps running and can be awaited again). Both errors carry the wait's `timing`; `onProgress` sees each
203
- * state change (queued, capacity_pending, running, with the server's reason) as it is observed.
221
+ * state change (queued, capacity_pending, running, with the server's reason) as it is observed. A start waiting in
222
+ * capacity_pending gives up at its deadline (`deadlineAt` on the phase event) and fails with `capacity_unavailable`
223
+ * (`err.retryable` true: nothing was started, retry later); the SDK does not retry it.
204
224
  */
205
225
  waitForOperation(operationId: string, opts?: WaitOptions): Promise<Operation>;
206
226
  /** One operation (GET /v1/operations/{id}); lifecycle operations stay pollable after a workspace is deleted. */
@@ -283,6 +303,8 @@ export declare class WorkspacesApi {
283
303
  operation: Operation;
284
304
  workspace: Workspace;
285
305
  }>;
306
+ /** The workspace's text inputs `{NAME: value}` (0.7.0; contracts §24.3). Secret inputs are bound secrets, never listed. */
307
+ inputs(workspaceId: string): Promise<Record<string, string>>;
286
308
  operations(workspaceId: string, params?: {
287
309
  limit?: number;
288
310
  cursor?: string;
package/dist/client.js CHANGED
@@ -9,6 +9,7 @@ import { UsageApi } from "./usage.js";
9
9
  import { VolumesApi } from "./volumes.js";
10
10
  import { AFTER_WAIT, TRACE, runLifecycle } from "./lifecycle.js";
11
11
  import { Trace, combineListeners, traced } from "./progress.js";
12
+ import { CaptureRegistry } from "./capture.js";
12
13
  /**
13
14
  * The workspace a key names (contracts §19.11): the live row (deleted_at null) when there is one, since at most one live
14
15
  * workspace holds a key; otherwise the newest tombstone (ended sessions leave tombstones with the same key, and a
@@ -76,6 +77,8 @@ export class WorkspacesApi {
76
77
  body.tools = params.tools;
77
78
  if (params.secrets !== undefined)
78
79
  body.secrets = params.secrets;
80
+ if (params.inputs !== undefined)
81
+ body.inputs = params.inputs;
79
82
  if (params.lifetime !== undefined)
80
83
  body.lifetime = params.lifetime;
81
84
  const waitOpts = params.wait === false ? null : (params.wait ?? {});
@@ -170,7 +173,9 @@ export class WorkspacesApi {
170
173
  * that does not wait is polled with bounded exponential backoff (+-20 % jitter). Resolves when the operation
171
174
  * succeeds; throws OperationFailedError when it fails or is canceled, OperationTimeoutError after `timeoutMs` (the
172
175
  * operation keeps running and can be awaited again). Both errors carry the wait's `timing`; `onProgress` sees each
173
- * state change (queued, capacity_pending, running, with the server's reason) as it is observed.
176
+ * state change (queued, capacity_pending, running, with the server's reason) as it is observed. A start waiting in
177
+ * capacity_pending gives up at its deadline (`deadlineAt` on the phase event) and fails with `capacity_unavailable`
178
+ * (`err.retryable` true: nothing was started, retry later); the SDK does not retry it.
174
179
  */
175
180
  async waitForOperation(operationId, opts = {}) {
176
181
  const inherited = opts[TRACE];
@@ -297,7 +302,13 @@ export class WorkspacesApi {
297
302
  }
298
303
  #op(kind, workspaceId, json, opts) {
299
304
  const path = `/v1/workspaces/${encodeURIComponent(workspaceId)}${kind === 'delete' ? '' : `/${kind}`}`;
300
- return runLifecycle(this.#ctx(), kind, workspaceId, async (init) => (await this.#lifecycle(kind === 'delete' ? 'DELETE' : 'POST', path, json, opts.idempotencyKey, init)).operation, opts);
305
+ return runLifecycle(this.#ctx(), kind, workspaceId, async (init) => {
306
+ const { operation } = await this.#lifecycle(kind === 'delete' ? 'DELETE' : 'POST', path, json, opts.idempotencyKey, init);
307
+ // Pending tool-call capture writes would land on a deleted or wiped disk: dropped once the change is accepted.
308
+ if (kind === 'delete' || kind === 'reset')
309
+ this.#ctx().captures.discard(workspaceId, kind);
310
+ return operation;
311
+ }, opts, { settle: kind === 'suspend' || kind === 'snapshot' });
301
312
  }
302
313
  delete(workspaceId, opts = {}) {
303
314
  return this.#op('delete', workspaceId, undefined, opts);
@@ -320,8 +331,9 @@ export class WorkspacesApi {
320
331
  const operation = await runLifecycle(this.#ctx(), 'close', workspaceId, async (init) => {
321
332
  const out = await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/close`, undefined, opts.idempotencyKey, init);
322
333
  workspace = out.workspace;
334
+ this.#ctx().captures.discard(workspaceId, 'delete'); // the session is deleted
323
335
  return out.operation;
324
- }, opts);
336
+ }, opts, { settle: true });
325
337
  return { operation, workspace: workspace };
326
338
  }
327
339
  reset(workspaceId, opts = {}) {
@@ -333,7 +345,8 @@ export class WorkspacesApi {
333
345
  * captured briefly (`operation`, layer_snapshot); poll `build` with templates.builds.waitForBuild. Owners/admins and
334
346
  * API keys with a tool permission only.
335
347
  */
336
- saveAsTemplate(workspaceId, params) {
348
+ async saveAsTemplate(workspaceId, params) {
349
+ await this.#ctx().captures.settle(workspaceId); // captured tool calls are part of the saved layer
337
350
  return this.#http.json('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/save-as-template`, { json: saveAsTemplateBody(params), idempotencyKey: params.idempotencyKey ?? randomId('save-') }, this.#auth);
338
351
  }
339
352
  async fork(workspaceId, target, opts = {}) {
@@ -348,9 +361,14 @@ export class WorkspacesApi {
348
361
  await trace.span('view', () => copy.refresh());
349
362
  await opts[AFTER_WAIT]?.(trace, op);
350
363
  },
351
- });
364
+ }, { settle: true });
352
365
  return { operation, workspace: copy };
353
366
  }
367
+ /** The workspace's text inputs `{NAME: value}` (0.7.0; contracts §24.3). Secret inputs are bound secrets, never listed. */
368
+ async inputs(workspaceId) {
369
+ const body = await this.#http.json('GET', `/v1/workspaces/${encodeURIComponent(workspaceId)}/inputs`, {}, this.#auth);
370
+ return body.inputs;
371
+ }
354
372
  async operations(workspaceId, params = {}) {
355
373
  const page = await this.#http.json('GET', `/v1/workspaces/${encodeURIComponent(workspaceId)}/operations`, { query: { limit: params.limit, cursor: params.cursor, state: params.state, kind: params.kind } }, this.#auth);
356
374
  return { data: page.data, nextCursor: page.next_cursor };
@@ -429,6 +447,7 @@ export class Shardflux {
429
447
  sleep,
430
448
  workspaces: this.workspaces,
431
449
  onProgress: opts.onProgress,
450
+ captures: new CaptureRegistry(),
432
451
  };
433
452
  }
434
453
  /** The authenticated principal (the API key, its organization and project). */
package/dist/egress.d.ts CHANGED
@@ -122,6 +122,24 @@ export interface WorkspaceEgressPolicy {
122
122
  /** Active organization override (null: none); when set, `effective.source` is 'organization'. */
123
123
  organization_override: OrganizationEgressOverride | null;
124
124
  enforcement: EgressEnforcement;
125
+ /**
126
+ * The template version's network ceiling (0.7.0; contracts §24.3 `defaults.egress`), or null. The host enforces
127
+ * `effective` intersected with it; a workspace policy it would narrow is refused with 422 egress_widening.
128
+ */
129
+ template_egress: {
130
+ mode: 'allowlist' | 'none';
131
+ allow_hosts: string[];
132
+ } | null;
133
+ /** What the host enforces (0.7.0): `effective` intersected with `template_egress` (equal to `effective` without one). */
134
+ effective_policy: {
135
+ mode: EgressMode;
136
+ rules: Array<{
137
+ host: string;
138
+ ports: number[];
139
+ protocols: Array<'tcp'>;
140
+ }>;
141
+ cidrs: string[];
142
+ };
125
143
  }
126
144
  export declare class EgressPolicyApi {
127
145
  #private;
package/dist/errors.d.ts CHANGED
@@ -13,8 +13,15 @@ export type ErrorCode = AppErrorCode | CellErrorCode;
13
13
  * file_list_indexing (retryable), guest_feature_unavailable; 422 validation_failed confirm_destructive_required,
14
14
  * reserved_key_prefix, invalid_defaults, update_policy_not_available, invalid_path, too_many_acknowledged_findings;
15
15
  * 403 forbidden template_dev_mode_role; 404 not_found draft_not_found, version_not_found, path_not_found.
16
+ * The template editor (contracts §24.6, 0.7.0) added: 422 validation_failed invalid_recipe, base_not_layered,
17
+ * language_unavailable, language_conflict, invalid_package, too_many_files, platform_owned_path, upload_required,
18
+ * upload_missing, upload_digest_mismatch, upload_too_large, extra_hosts_without_auto, invalid_settings,
19
+ * services_unsupported, input_required, input_unknown, input_invalid, egress_widening, reserved_session_id,
20
+ * env_collision, reserved_template_slug; 409 conflict package_index_unavailable; 404 package_not_found; the operation
21
+ * error `startup_failed` (details.reason startup_failed, service_not_ready, secrets_unavailable, secret_not_available,
22
+ * guest_feature_unavailable).
16
23
  */
17
- export type KnownErrorReason = 'workspace_not_running' | 'operation_in_progress' | 'workspace_deleted' | 'secret_not_available' | 'legacy_disk_layout' | 'not_session' | 'session_lifetime' | 'lifetime_mismatch' | 'not_resettable' | 'template_not_layered' | 'draft_exists' | 'draft_stale' | 'build_in_progress' | 'file_list_unavailable' | 'file_list_indexing' | 'guest_feature_unavailable' | 'confirm_destructive_required' | 'reserved_key_prefix' | 'invalid_defaults' | 'update_policy_not_available' | 'invalid_path' | 'too_many_acknowledged_findings' | 'template_dev_mode_role' | 'draft_not_found' | 'version_not_found' | 'path_not_found';
24
+ export type KnownErrorReason = 'invalid_recipe' | 'base_not_layered' | 'language_unavailable' | 'language_conflict' | 'invalid_package' | 'too_many_files' | 'platform_owned_path' | 'upload_required' | 'upload_missing' | 'upload_digest_mismatch' | 'upload_too_large' | 'extra_hosts_without_auto' | 'invalid_settings' | 'services_unsupported' | 'input_required' | 'input_unknown' | 'input_invalid' | 'egress_widening' | 'reserved_session_id' | 'env_collision' | 'reserved_template_slug' | 'package_index_unavailable' | 'package_not_found' | 'startup_failed' | 'service_not_ready' | 'secrets_unavailable' | 'workspace_not_running' | 'operation_in_progress' | 'workspace_deleted' | 'secret_not_available' | 'legacy_disk_layout' | 'not_session' | 'session_lifetime' | 'lifetime_mismatch' | 'not_resettable' | 'template_not_layered' | 'draft_exists' | 'draft_stale' | 'build_in_progress' | 'file_list_unavailable' | 'file_list_indexing' | 'guest_feature_unavailable' | 'confirm_destructive_required' | 'reserved_key_prefix' | 'invalid_defaults' | 'update_policy_not_available' | 'invalid_path' | 'too_many_acknowledged_findings' | 'template_dev_mode_role' | 'draft_not_found' | 'version_not_found' | 'path_not_found';
18
25
  /** A known reason, or any other string the server sends (reasons are open-ended). */
19
26
  export type ErrorReason = KnownErrorReason | (string & {});
20
27
  export interface ErrorBodyLike {
@@ -53,13 +60,19 @@ type Operation = AppComponents['schemas']['Operation'];
53
60
  /**
54
61
  * Waiting for an operation ran out of time. The operation keeps running server side:
55
62
  * resume with `cloud.workspaces.waitForOperation(err.operationId)` or call open() again
56
- * (it returns the same operation while it is active).
63
+ * (it returns the same operation while it is active). A start still waiting for capacity
64
+ * (`capacity_pending`) gives up at `deadlineAt` and then fails with `capacity_unavailable`.
57
65
  */
58
66
  export declare class OperationTimeoutError extends Error {
59
67
  readonly operationId: string;
60
68
  readonly workspaceId: string | null;
61
69
  readonly lastState: Operation['state'];
62
70
  readonly lastReason: string | null;
71
+ /**
72
+ * When the operation was last `capacity_pending`: when it gives up waiting for a host (`error.details.deadline_at`,
73
+ * RFC 3339) and fails with `capacity_unavailable`. Null in other states or from an API that does not report it.
74
+ */
75
+ readonly deadlineAt: string | null;
63
76
  readonly waitedMs: number;
64
77
  /** Where the time went: client phases, retries and the operation's own server timing so far. */
65
78
  timing: LifecycleTiming | undefined;
@@ -70,6 +83,12 @@ export declare class OperationFailedError extends Error {
70
83
  readonly operation: Operation;
71
84
  readonly operationId: string;
72
85
  readonly errorCode: string | null;
86
+ /**
87
+ * `operation.error.retryable` (false when absent): the same request may succeed later. `capacity_unavailable` is
88
+ * retryable: no host could admit the start before its deadline, nothing was started, retry later. The SDK never
89
+ * retries a failed operation itself.
90
+ */
91
+ readonly retryable: boolean;
73
92
  /** Where the time went before the operation failed. */
74
93
  timing: LifecycleTiming | undefined;
75
94
  constructor(op: Operation);
package/dist/errors.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { capacityDeadlineOf } from "./progress.js";
1
2
  export function isErrorBody(v) {
2
3
  if (typeof v !== 'object' || v === null || !('error' in v))
3
4
  return false;
@@ -45,23 +46,32 @@ export class ShardfluxProtocolError extends Error {
45
46
  /**
46
47
  * Waiting for an operation ran out of time. The operation keeps running server side:
47
48
  * resume with `cloud.workspaces.waitForOperation(err.operationId)` or call open() again
48
- * (it returns the same operation while it is active).
49
+ * (it returns the same operation while it is active). A start still waiting for capacity
50
+ * (`capacity_pending`) gives up at `deadlineAt` and then fails with `capacity_unavailable`.
49
51
  */
50
52
  export class OperationTimeoutError extends Error {
51
53
  operationId;
52
54
  workspaceId;
53
55
  lastState;
54
56
  lastReason;
57
+ /**
58
+ * When the operation was last `capacity_pending`: when it gives up waiting for a host (`error.details.deadline_at`,
59
+ * RFC 3339) and fails with `capacity_unavailable`. Null in other states or from an API that does not report it.
60
+ */
61
+ deadlineAt;
55
62
  waitedMs;
56
63
  /** Where the time went: client phases, retries and the operation's own server timing so far. */
57
64
  timing = undefined;
58
65
  constructor(op, waitedMs) {
59
- super(`Operation ${op.id} (${op.kind}) is still ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''} after ${Math.round(waitedMs)} ms; it continues server side.`);
66
+ const deadlineAt = capacityDeadlineOf(op);
67
+ const after = deadlineAt ? `it keeps waiting for a host server side until ${deadlineAt} and fails with capacity_unavailable if none admits it by then.` : 'it continues server side.';
68
+ super(`Operation ${op.id} (${op.kind}) is still ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''} after ${Math.round(waitedMs)} ms; ${after}`);
60
69
  this.name = 'OperationTimeoutError';
61
70
  this.operationId = op.id;
62
71
  this.workspaceId = op.workspace_id;
63
72
  this.lastState = op.state;
64
73
  this.lastReason = op.state_reason ?? null;
74
+ this.deadlineAt = deadlineAt;
65
75
  this.waitedMs = waitedMs;
66
76
  }
67
77
  }
@@ -70,6 +80,12 @@ export class OperationFailedError extends Error {
70
80
  operation;
71
81
  operationId;
72
82
  errorCode;
83
+ /**
84
+ * `operation.error.retryable` (false when absent): the same request may succeed later. `capacity_unavailable` is
85
+ * retryable: no host could admit the start before its deadline, nothing was started, retry later. The SDK never
86
+ * retries a failed operation itself.
87
+ */
88
+ retryable;
73
89
  /** Where the time went before the operation failed. */
74
90
  timing = undefined;
75
91
  constructor(op) {
@@ -79,5 +95,6 @@ export class OperationFailedError extends Error {
79
95
  this.operation = op;
80
96
  this.operationId = op.id;
81
97
  this.errorCode = code;
98
+ this.retryable = op.error?.retryable === true;
82
99
  }
83
100
  }