@shardflux/sdk 0.8.0 → 0.9.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/client.d.ts CHANGED
@@ -9,8 +9,10 @@
9
9
  */
10
10
  import type { components, operations } from './generated/app-api.js';
11
11
  import { ShardfluxApiError } from './errors.js';
12
+ import type { WorkspaceMode } from './errors.js';
12
13
  import { HttpClient } from './http.js';
13
- import type { ToolName } from './tokens.js';
14
+ import type { RequestOptions } from './http.js';
15
+ import type { ToolName, ToolToken } from './tokens.js';
14
16
  import { Workspace } from './workspace.js';
15
17
  import { AuditApi } from './audit.js';
16
18
  import { EgressPolicyApi } from './egress.js';
@@ -19,9 +21,11 @@ import { TemplatesApi } from './templates.js';
19
21
  import type { SaveAsTemplateParams, SaveAsTemplateResponse } from './templates.js';
20
22
  import { UsageApi } from './usage.js';
21
23
  import { VolumesApi } from './volumes.js';
22
- import type { FinishedOperation, InternalLifecycleOptions, LifecycleOptions, WaitedLifecycleOptions } from './lifecycle.js';
24
+ import type { VersionCheckOption } from './version-check.js';
25
+ import type { FinishedOperation, InternalLifecycleOptions, LifecycleOptions, ResumeOptions, WaitedLifecycleOptions, WaitedResumeOptions } from './lifecycle.js';
23
26
  import type { ProgressListener } from './progress.js';
24
27
  import { CaptureRegistry } from './capture.js';
28
+ import type { FeedbackReceipt, SendFeedbackParams } from './feedback.js';
25
29
  export type WorkspaceView = components['schemas']['Workspace'];
26
30
  export type Operation = components['schemas']['Operation'];
27
31
  /** persistent (kept until deleted) or session (discarded when the session ends: close(), idle timeout; contracts §19.11). */
@@ -52,6 +56,32 @@ type Ok<Op> = Op extends {
52
56
  [K in keyof R]: K extends 200 | 201 | 202 ? JsonOf<R[K]> : never;
53
57
  }[keyof R] : never;
54
58
  export type OpenResponse = Ok<operations['postV1WorkspacesOpen']>;
59
+ /** POST /v1/workspaces/{id}/resume: the held 200 (contracts §22.6) or the lifecycle 202. */
60
+ export type ResumeResponse = Ok<operations['postV1WorkspacesWorkspaceIdResume']>;
61
+ /** What a resume request answered (0.9.0; `WorkspacesApi.requestResume`). */
62
+ export interface ResumeAnswer {
63
+ /**
64
+ * True for a held resume that ended with the workspace running (200 with `Preference-Applied`): `workspace` and
65
+ * `toolToken` are current. False for any other answer: wait for `operation` (an older server answers 202 at once).
66
+ */
67
+ ready: boolean;
68
+ /** The resume, or the open/resume it joined; null when a held resume found the workspace already running. */
69
+ operation: Operation | null;
70
+ workspace: WorkspaceView;
71
+ /** The tool token of a ready answer (null when the API could not issue one: the first tool call fetches it). */
72
+ toolToken: ToolToken | null;
73
+ requestId: string | null;
74
+ }
75
+ export interface ResumeRequestOptions {
76
+ /** Seconds the server may hold the request (`Prefer: wait`, at most 20); below 1 the request is not held. */
77
+ waitS: number;
78
+ /** The tool token a held resume returns: agent label and tools (default all the key's tools, label `default`). */
79
+ agentLabel?: string | undefined;
80
+ tools?: readonly ToolName[] | undefined;
81
+ idempotencyKey?: string | undefined;
82
+ signal?: AbortSignal | undefined;
83
+ onRetry?: RequestOptions['onRetry'] | undefined;
84
+ }
55
85
  export type AgentSession = Ok<operations['getV1WorkspacesWorkspaceIdAgentSessions']>['data'][number];
56
86
  export type Entitlements = Ok<operations['getV1OrganizationsOrganizationIdEntitlements']>;
57
87
  export type Me = Ok<operations['getV1Me']>;
@@ -83,6 +113,13 @@ export interface ShardfluxOptions {
83
113
  * retries and busy waits of tool calls): for logs or telemetry. Per-call `onProgress` listeners get their own events too.
84
114
  */
85
115
  onProgress?: ProgressListener;
116
+ /**
117
+ * The automatic version check (0.9.0; contracts §30.4): after the first successful API response of the process, a
118
+ * background GET /v1/client-versions (3 s timeout, errors swallowed) emits a `ShardfluxUpdateWarning` when this
119
+ * package is outdated or unsupported. Default true (`@shardflux/sdk` at SDK_VERSION); tools built on the SDK pass
120
+ * their own `{ package, version }` or `false`. `SHARDFLUX_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1` turn it off.
121
+ */
122
+ versionCheck?: VersionCheckOption;
86
123
  }
87
124
  export interface Caps {
88
125
  cpu_millis?: number;
@@ -144,6 +181,16 @@ export interface OpenParams {
144
181
  * with another value is ShardfluxApiError 409 (details.reason `lifetime_mismatch`).
145
182
  */
146
183
  lifetime?: WorkspaceLifetime;
184
+ /**
185
+ * `file_first` (0.9.0; contracts §29): the workspace is a versioned file tree with no VM between executions. It is
186
+ * ready at once (no operation: the open answers with a tool token), never suspended, and runs commands as executions
187
+ * (`workspace.executions.run()`): a fresh VM on the latest tree whose changed files become the next tree revision;
188
+ * nothing else survives an execution. Needs a layered template version (409 `layout_unsupported` otherwise) and is
189
+ * persistent (`lifetime: 'session'` with it is 422 `not_supported_for_mode`). Omitted: `processful` for a new key, the
190
+ * stored mode for an existing one. Immutable: reopening a key with another mode is 409 `mode_mismatch`; 422
191
+ * `mode_not_available` while the deployment does not offer file-first workspaces.
192
+ */
193
+ mode?: WorkspaceMode;
147
194
  /** `false`: return immediately (possibly not ready). Default: wait until ready. */
148
195
  wait?: false | WaitOptions;
149
196
  /** Defaults to a fresh key per open() call so transport retries replay instead of duplicating. */
@@ -252,9 +299,24 @@ export declare class WorkspacesApi {
252
299
  */
253
300
  suspend(workspaceId: string, opts: WaitedLifecycleOptions): Promise<FinishedOperation>;
254
301
  suspend(workspaceId: string, opts?: LifecycleOptions): Promise<Operation>;
255
- /** Resumes a suspended workspace. Resolves when the resume is requested; with `wait`, once the workspace runs. */
256
- resume(workspaceId: string, opts: WaitedLifecycleOptions): Promise<FinishedOperation>;
257
- resume(workspaceId: string, opts?: LifecycleOptions): Promise<Operation>;
302
+ /**
303
+ * Resumes a suspended workspace. Resolves when the resume is requested; with `wait`, once the workspace runs. With
304
+ * `wait` (0.9.0) the request is held by the server until the workspace runs (contracts §22.6: one request, timing phase
305
+ * `request` with reason `held`); a server that does not hold it answers at once and the operation is polled.
306
+ * `serverWait: false` polls only. `agentLabel`/`tools` choose the tool token the held answer carries (attribution
307
+ * only here; `workspace.resume()` keeps it). A workspace that is already running is ShardfluxApiError 409 `conflict`
308
+ * (details.reason `already_running`), with or without `wait`.
309
+ */
310
+ resume(workspaceId: string, opts: WaitedResumeOptions): Promise<FinishedOperation>;
311
+ resume(workspaceId: string, opts?: ResumeOptions): Promise<Operation>;
312
+ /**
313
+ * One `POST /v1/workspaces/{id}/resume` (0.9.0), held for up to `waitS` seconds when that is at least 1 (`Prefer:
314
+ * wait`, contracts §22.6), asking for the tool token of `agentLabel`/`tools`. `ready` only for a 200 the server says it
315
+ * held (`Preference-Applied`); every other answer is the operation to wait for, so a server without the held resume
316
+ * works unchanged. Refusals (409 operation_in_progress, already_running without the hold, ...) throw as usual.
317
+ * `Workspace.wake()` and `resume({ wait })` use it; most callers want those.
318
+ */
319
+ requestResume(workspaceId: string, o: ResumeRequestOptions): Promise<ResumeAnswer>;
258
320
  /** Takes a snapshot. Resolves when it is requested; with `wait`, once it is taken. */
259
321
  snapshot(workspaceId: string, opts: WaitedLifecycleOptions & {
260
322
  label?: string;
@@ -354,6 +416,15 @@ export declare class Shardflux {
354
416
  /** The authenticated principal (the API key, its organization and project). */
355
417
  me(): Promise<Me>;
356
418
  entitlements(organizationId: string): Promise<Entitlements>;
419
+ /**
420
+ * Send feedback straight to the Shardflux founder (0.9.0+; POST /v1/feedback), who reads every message. Use it while
421
+ * you work, the moment something fails unexpectedly, an error or doc is confusing, something is missing or slow, or a
422
+ * workaround was needed; short and specific beats polished, and `context.requestId` / `errorCode` let the founder find
423
+ * the logs. `context.client` defaults to `shardflux-sdk-ts/<SDK_VERSION>`. Returns `{ id, receivedAt, duplicate }`
424
+ * (`duplicate`: the same message from this key within 24 hours; no second email). 429 `rate_limited` (with
425
+ * `retryAfterSeconds`) and 422 `validation_failed` are ShardfluxApiErrors; the call is never retried.
426
+ */
427
+ sendFeedback(params: SendFeedbackParams): Promise<FeedbackReceipt>;
357
428
  /** Raw access to any /v1 endpoint with the SDK's authentication and error handling. */
358
429
  request<T>(method: string, path: string, init?: Parameters<HttpClient['json']>[2]): Promise<T>;
359
430
  }
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { OperationFailedError, OperationTimeoutError, ShardfluxApiError } from "./errors.js";
1
+ import { OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
2
2
  import { HttpClient, SDK_VERSION, SERVER_WAIT_MAX_S, defaultFetch, defaultSleep, pollWithWait, randomId } from "./http.js";
3
3
  import { Workspace } from "./workspace.js";
4
4
  import { AuditApi } from "./audit.js";
@@ -7,9 +7,11 @@ import { SecretsApi } from "./secrets.js";
7
7
  import { TemplatesApi, saveAsTemplateBody } from "./templates.js";
8
8
  import { UsageApi } from "./usage.js";
9
9
  import { VolumesApi } from "./volumes.js";
10
- import { AFTER_WAIT, TRACE, runLifecycle } from "./lifecycle.js";
10
+ import { versionCheckHook } from "./version-check.js";
11
+ import { AFTER_WAIT, HELD_RESUME, TRACE, runLifecycle, waitOptionsOf } from "./lifecycle.js";
11
12
  import { Trace, combineListeners, traced } from "./progress.js";
12
13
  import { CaptureRegistry } from "./capture.js";
14
+ import { sendFeedback } from "./feedback.js";
13
15
  /**
14
16
  * The workspace a key names (contracts §19.11): the live row (deleted_at null) when there is one, since at most one live
15
17
  * workspace holds a key; otherwise the newest tombstone (ended sessions leave tombstones with the same key, and a
@@ -29,6 +31,21 @@ export function pickByKey(rows, key) {
29
31
  return tomb;
30
32
  }
31
33
  const TERMINAL = new Set(['succeeded', 'failed', 'canceled']);
34
+ /**
35
+ * `resume({ wait })` of a workspace that is already running: a held resume answers 200 without an operation (contracts
36
+ * §22.6); the SDK reports it as the API does without the hold, 409 `conflict` `already_running`.
37
+ */
38
+ function alreadyRunning(answer) {
39
+ return new ShardfluxApiError(409, {
40
+ error: {
41
+ code: 'conflict',
42
+ message: 'Only a suspended workspace can be resumed; this one is already running.',
43
+ request_id: answer.requestId ?? '',
44
+ retryable: false,
45
+ details: { reason: 'already_running', observed_state: answer.workspace.observed_state, desired_state: answer.workspace.desired_state },
46
+ },
47
+ }, 'api');
48
+ }
32
49
  /** Races the injected sleep against the signal (the injected sleep itself may not be abortable). */
33
50
  function abortableSleep(sleep, ms, signal) {
34
51
  if (signal.aborted)
@@ -81,6 +98,8 @@ export class WorkspacesApi {
81
98
  body.inputs = params.inputs;
82
99
  if (params.lifetime !== undefined)
83
100
  body.lifetime = params.lifetime;
101
+ if (params.mode !== undefined)
102
+ body.mode = params.mode;
84
103
  const waitOpts = params.wait === false ? null : (params.wait ?? {});
85
104
  const trace = new Trace('open', combineListeners(this.#ctx().onProgress, params.onProgress, waitOpts?.onProgress));
86
105
  return traced(trace, async () => {
@@ -188,7 +207,7 @@ export class WorkspacesApi {
188
207
  return traced(trace, run);
189
208
  }
190
209
  async #poll(operationId, opts, trace) {
191
- const { sleep, http, authorization } = this.#ctx();
210
+ const { sleep, http } = this.#ctx();
192
211
  const timeoutMs = opts.timeoutMs ?? 300_000;
193
212
  const maxInterval = opts.maxPollIntervalMs ?? 5_000;
194
213
  let interval = opts.pollIntervalMs ?? 250;
@@ -200,7 +219,8 @@ export class WorkspacesApi {
200
219
  throw aborted();
201
220
  const waitS = opts.serverWait === false ? 0 : (timeoutMs - (Date.now() - started)) / 1000;
202
221
  const t0 = Date.now();
203
- const { body, applied } = await pollWithWait(http, `/v1/operations/${encodeURIComponent(operationId)}`, authorization, waitS, opts.signal, trace.onRetry);
222
+ // Read per poll: a user session (ShardfluxAccount) may rotate its token while the wait runs.
223
+ const { body, applied } = await pollWithWait(http, `/v1/operations/${encodeURIComponent(operationId)}`, this.#ctx().authorization, waitS, opts.signal, trace.onRetry);
204
224
  const operation = body.operation;
205
225
  trace.observe(operation);
206
226
  if (operation.state === 'succeeded')
@@ -317,7 +337,83 @@ export class WorkspacesApi {
317
337
  return this.#op('suspend', workspaceId, undefined, opts);
318
338
  }
319
339
  resume(workspaceId, opts = {}) {
320
- return this.#op('resume', workspaceId, undefined, opts);
340
+ const waitOpts = waitOptionsOf(opts);
341
+ if (!waitOpts || waitOpts.serverWait === false)
342
+ return this.#op('resume', workspaceId, undefined, opts);
343
+ const trace = new Trace('resume', combineListeners(this.#ctx().onProgress, opts.onProgress, waitOpts.onProgress), { workspaceId });
344
+ return traced(trace, async () => {
345
+ const timeoutMs = waitOpts.timeoutMs ?? 300_000;
346
+ const started = Date.now();
347
+ // A handle's target takes the answer; by id, `agentLabel`/`tools` only choose the token (attribution).
348
+ const target = opts[HELD_RESUME] ?? { agentLabel: opts.agentLabel, tools: opts.tools, adopt: () => undefined };
349
+ const waitS = Math.min(SERVER_WAIT_MAX_S, Math.floor(timeoutMs / 1000));
350
+ trace.phase('request', waitS >= 1 ? 'held' : null);
351
+ const answer = await this.requestResume(workspaceId, {
352
+ waitS,
353
+ agentLabel: target.agentLabel,
354
+ tools: target.tools,
355
+ idempotencyKey: opts.idempotencyKey,
356
+ signal: waitOpts.signal,
357
+ onRetry: trace.onRetry,
358
+ });
359
+ if (answer.operation)
360
+ trace.observe(answer.operation);
361
+ if (answer.ready) {
362
+ // Held (200): the running workspace and its token, no wait, view or token request.
363
+ target.adopt(answer.workspace, answer.toolToken);
364
+ if (!answer.operation)
365
+ throw alreadyRunning(answer);
366
+ return answer.operation;
367
+ }
368
+ const operation = answer.operation;
369
+ if (operation.state === 'failed' || operation.state === 'canceled')
370
+ throw new OperationFailedError(operation);
371
+ let final = operation;
372
+ if (operation.state !== 'succeeded') {
373
+ const left = waitS >= 1 ? { timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)) } : {};
374
+ final = await this.waitForOperation(operation.id, { ...waitOpts, ...left, [TRACE]: trace });
375
+ }
376
+ await opts[AFTER_WAIT]?.(trace, final);
377
+ return final;
378
+ });
379
+ }
380
+ /**
381
+ * One `POST /v1/workspaces/{id}/resume` (0.9.0), held for up to `waitS` seconds when that is at least 1 (`Prefer:
382
+ * wait`, contracts §22.6), asking for the tool token of `agentLabel`/`tools`. `ready` only for a 200 the server says it
383
+ * held (`Preference-Applied`); every other answer is the operation to wait for, so a server without the held resume
384
+ * works unchanged. Refusals (409 operation_in_progress, already_running without the hold, ...) throw as usual.
385
+ * `Workspace.wake()` and `resume({ wait })` use it; most callers want those.
386
+ */
387
+ async requestResume(workspaceId, o) {
388
+ const s = Math.min(SERVER_WAIT_MAX_S, Math.floor(o.waitS));
389
+ const held = s >= 1;
390
+ const body = {};
391
+ if (held && o.agentLabel !== undefined)
392
+ body.agent_label = o.agentLabel;
393
+ if (held && o.tools !== undefined)
394
+ body.tools = [...o.tools];
395
+ const init = {
396
+ idempotencyKey: o.idempotencyKey ?? randomId('op-'),
397
+ ...(Object.keys(body).length > 0 ? { json: body } : {}),
398
+ ...(o.signal ? { signal: o.signal } : {}),
399
+ ...(o.onRetry ? { onRetry: o.onRetry } : {}),
400
+ };
401
+ if (held) {
402
+ init.headers = { prefer: `wait=${s}` };
403
+ // The held response may take the whole wait: the request timeout must outlast it.
404
+ init.timeoutMs = Math.max(this.#http.opts.timeoutMs, s * 1000 + 10_000);
405
+ }
406
+ const res = await this.#http.jsonWithHeaders('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/resume`, init, this.#auth);
407
+ const b = res.body;
408
+ if (!b || typeof b !== 'object' || !b.workspace)
409
+ throw new ShardfluxProtocolError('resume: response has no workspace', res.status);
410
+ const pa = res.headers.get('preference-applied');
411
+ const ready = held && res.status === 200 && pa !== null && /\bwait\s*=/i.test(pa);
412
+ if (!ready && !b.operation)
413
+ throw new ShardfluxProtocolError('resume: response has no operation', res.status);
414
+ const toolToken = ready ? (b.tool_token ?? null) : null;
415
+ this.#noteToken(toolToken);
416
+ return { ready, operation: b.operation ?? null, workspace: b.workspace, toolToken, requestId: res.headers.get('x-request-id') };
321
417
  }
322
418
  snapshot(workspaceId, opts = {}) {
323
419
  return this.#op('snapshot', workspaceId, opts.label === undefined ? {} : { label: opts.label }, opts);
@@ -439,8 +535,9 @@ export class Shardflux {
439
535
  this.egress = new EgressPolicyApi(() => this.#ctx);
440
536
  this.audit = new AuditApi(() => this.#ctx);
441
537
  this.volumes = new VolumesApi(() => this.#ctx);
538
+ const baseUrl = opts.baseUrl ?? 'https://api.shardflux.dev';
442
539
  this.#ctx = {
443
- http: new HttpClient({ baseUrl: opts.baseUrl ?? 'https://api.shardflux.dev', fetch: f, userAgent, timeoutMs: opts.timeoutMs ?? 30_000, maxRetries: opts.maxRetries ?? 2, source: 'api', sleep }),
540
+ http: new HttpClient({ baseUrl, fetch: f, userAgent, timeoutMs: opts.timeoutMs ?? 30_000, maxRetries: opts.maxRetries ?? 2, source: 'api', sleep, onSuccess: versionCheckHook(opts.versionCheck, baseUrl, f, userAgent) }),
444
541
  authorization: `Bearer ${opts.apiKey}`,
445
542
  fetch: f,
446
543
  userAgent,
@@ -457,6 +554,17 @@ export class Shardflux {
457
554
  entitlements(organizationId) {
458
555
  return this.#ctx.http.json('GET', `/v1/organizations/${encodeURIComponent(organizationId)}/entitlements`, {}, this.#ctx.authorization);
459
556
  }
557
+ /**
558
+ * Send feedback straight to the Shardflux founder (0.9.0+; POST /v1/feedback), who reads every message. Use it while
559
+ * you work, the moment something fails unexpectedly, an error or doc is confusing, something is missing or slow, or a
560
+ * workaround was needed; short and specific beats polished, and `context.requestId` / `errorCode` let the founder find
561
+ * the logs. `context.client` defaults to `shardflux-sdk-ts/<SDK_VERSION>`. Returns `{ id, receivedAt, duplicate }`
562
+ * (`duplicate`: the same message from this key within 24 hours; no second email). 429 `rate_limited` (with
563
+ * `retryAfterSeconds`) and 422 `validation_failed` are ShardfluxApiErrors; the call is never retried.
564
+ */
565
+ sendFeedback(params) {
566
+ return sendFeedback(this.#ctx, params);
567
+ }
460
568
  /** Raw access to any /v1 endpoint with the SDK's authentication and error handling. */
461
569
  request(method, path, init = {}) {
462
570
  return this.#ctx.http.json(method, path, init, this.#ctx.authorization);
package/dist/errors.d.ts CHANGED
@@ -19,9 +19,27 @@ export type ErrorCode = AppErrorCode | CellErrorCode;
19
19
  * services_unsupported, input_required, input_unknown, input_invalid, egress_widening, reserved_session_id,
20
20
  * env_collision, reserved_template_slug; 409 conflict package_index_unavailable; 404 package_not_found; the operation
21
21
  * error `startup_failed` (details.reason startup_failed, service_not_ready, secrets_unavailable, secret_not_available,
22
- * guest_feature_unavailable).
22
+ * guest_feature_unavailable). File tools and parking (contracts §25-§28, 0.9.0) added: 409 conflict revision_mismatch
23
+ * (details.current_revision); 422 validation_failed edit_not_found, edit_ambiguous (details.index), edit_not_text,
24
+ * patch_invalid; 503 service_unavailable host_capacity and wake_failed (both retryable, with Retry-After); 409
25
+ * workspace_busy workspace_fenced (a lifecycle operation holds the workspace; waited out like any workspace_busy).
26
+ * Reads of a sleeping workspace (§26.4/§26.5): 409 workspace_not_running offline_unavailable and offline_budget
27
+ * (retryable; the disk could not be read offline: the workspace is woken and the call retried like any
28
+ * workspace_not_running), 503 offline_changed (retryable; the retry is served by the guest).
29
+ * Hosts without the features (contracts §26.7, 0.9.0): 409 conflict host_feature_unavailable (not retryable;
30
+ * details.feature `file_search` or `file_patch`): the workspace runs on a host agent that predates the call, until it
31
+ * runs on an upgraded host (minutes to hours); read and write the file, or run a search command, instead.
32
+ * File-first workspaces (contracts §29.7, §29.8; 0.9.0) added: 409 conflict not_supported_for_mode (details.mode,
33
+ * details.operation; NotSupportedForModeError), mode_mismatch (reopening a key with another mode), layout_unsupported (a
34
+ * legacy template), tree_revision_mismatch (details.current_tree_revision; TreeRevisionMismatchError),
35
+ * execution_id_reused, operation_id_reused (an Idempotency-Key reused for another request); 422 validation_failed
36
+ * mode_not_available (the deployment does not offer file-first workspaces), outside_tree_root (also 404 on reads); 409
37
+ * workspace_busy execution_in_progress (details.execution_id; waited out like any workspace_busy); 503
38
+ * service_unavailable no_execution_host (retryable, Retry-After). An execution that ended `failed` or `lost` names its
39
+ * cause in `error.details.reason`: lease_expired, host_unreachable, host_restarted, tree_moved, blob_missing,
40
+ * blob_corrupt, exec_failed_to_start, among others.
23
41
  */
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';
42
+ 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' | 'revision_mismatch' | 'edit_not_found' | 'edit_ambiguous' | 'edit_not_text' | 'patch_invalid' | 'host_capacity' | 'wake_failed' | 'workspace_fenced' | 'offline_unavailable' | 'offline_budget' | 'offline_changed' | 'host_feature_unavailable' | 'not_supported_for_mode' | 'mode_mismatch' | 'mode_not_available' | 'layout_unsupported' | 'tree_revision_mismatch' | 'outside_tree_root' | 'execution_in_progress' | 'execution_id_reused' | 'operation_id_reused' | 'no_execution_host' | 'lease_expired' | 'host_unreachable' | 'host_restarted' | 'tree_moved' | 'blob_missing' | 'blob_corrupt' | 'exec_failed_to_start';
25
43
  /** A known reason, or any other string the server sends (reasons are open-ended). */
26
44
  export type ErrorReason = KnownErrorReason | (string & {});
27
45
  export interface ErrorBodyLike {
@@ -49,8 +67,49 @@ export declare class ShardfluxApiError extends Error {
49
67
  readonly reason: ErrorReason | undefined;
50
68
  /** Where the time went when a traced call (open, a waited lifecycle call, a wake) failed with this error. */
51
69
  timing: LifecycleTiming | undefined;
52
- constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number);
70
+ /**
71
+ * `X-Tree-Revision` of the refusal (file-first workspaces, contracts §29.8): the tree revision the refused call saw.
72
+ * Undefined when the response carried none (processful workspaces, the application API).
73
+ */
74
+ readonly treeRevision: number | undefined;
75
+ constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number);
76
+ }
77
+ /** processful (the default: one VM keeps processes, memory and files) or file_first (contracts §29). */
78
+ export type WorkspaceMode = AppComponents['schemas']['WorkspaceMode'];
79
+ /**
80
+ * The call does not exist for the workspace's mode (contracts §29.7, §29.8): 409 `conflict` with details.reason
81
+ * `not_supported_for_mode`, `details.mode` (the workspace's mode) and `details.operation`. File-first workspaces have no
82
+ * VM between executions, so exec sessions, PTY, processes, version control, browser, changes, suspend, resume,
83
+ * snapshot, fork, reset, save-as-template, volumes and idle policies are refused; processful workspaces have no
84
+ * executions and no tree revisions. When the handle knows the workspace's mode the SDK raises it before any request
85
+ * (`local` true); otherwise it is the server's refusal. Either way `err.reason === 'not_supported_for_mode'`.
86
+ */
87
+ export declare class NotSupportedForModeError extends ShardfluxApiError {
88
+ /** The workspace's mode (null when the server did not say). */
89
+ readonly mode: WorkspaceMode | null;
90
+ /** The refused call, e.g. `pty.open`, `suspend`, `executions.run` (null when the server did not say). */
91
+ readonly operation: string | null;
92
+ /** True when the SDK refused the call itself, without a request. */
93
+ readonly local: boolean;
94
+ constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number, local?: boolean);
95
+ /** The SDK's own refusal of `operation` for a workspace whose mode it knows (no request was made). */
96
+ static local(mode: WorkspaceMode, operation: string, source: 'api' | 'cell', message: string): NotSupportedForModeError;
53
97
  }
98
+ /**
99
+ * A file-first call with `ifTreeRevision` (`If-Match`) found the tree at another revision (contracts §29.8): 409
100
+ * `conflict`, details.reason `tree_revision_mismatch`. Nothing changed. `currentTreeRevision` is the revision the tree
101
+ * is at: read what changed, then retry against it.
102
+ */
103
+ export declare class TreeRevisionMismatchError extends ShardfluxApiError {
104
+ readonly currentTreeRevision: number | null;
105
+ constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number);
106
+ }
107
+ /**
108
+ * Builds the error for an error body, typed by its reason: NotSupportedForModeError for `not_supported_for_mode`,
109
+ * TreeRevisionMismatchError for `tree_revision_mismatch`, else ShardfluxApiError. Every SDK request builds its errors
110
+ * here, so `instanceof` works whichever call was refused.
111
+ */
112
+ export declare function apiError(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number): ShardfluxApiError;
54
113
  /** The response was not the documented shape (e.g. a proxy error page). */
55
114
  export declare class ShardfluxProtocolError extends Error {
56
115
  readonly status: number;
package/dist/errors.js CHANGED
@@ -19,7 +19,12 @@ export class ShardfluxApiError extends Error {
19
19
  reason;
20
20
  /** Where the time went when a traced call (open, a waited lifecycle call, a wake) failed with this error. */
21
21
  timing = undefined;
22
- constructor(status, body, source, retryAfterSeconds) {
22
+ /**
23
+ * `X-Tree-Revision` of the refusal (file-first workspaces, contracts §29.8): the tree revision the refused call saw.
24
+ * Undefined when the response carried none (processful workspaces, the application API).
25
+ */
26
+ treeRevision;
27
+ constructor(status, body, source, retryAfterSeconds, treeRevision) {
23
28
  super(body.error.message);
24
29
  this.name = 'ShardfluxApiError';
25
30
  this.status = status;
@@ -30,10 +35,69 @@ export class ShardfluxApiError extends Error {
30
35
  this.operationId = body.error.operation_id;
31
36
  this.source = source;
32
37
  this.retryAfterSeconds = retryAfterSeconds;
38
+ this.treeRevision = treeRevision;
33
39
  const reason = body.error.details?.reason;
34
40
  this.reason = typeof reason === 'string' ? reason : undefined;
35
41
  }
36
42
  }
43
+ /**
44
+ * The call does not exist for the workspace's mode (contracts §29.7, §29.8): 409 `conflict` with details.reason
45
+ * `not_supported_for_mode`, `details.mode` (the workspace's mode) and `details.operation`. File-first workspaces have no
46
+ * VM between executions, so exec sessions, PTY, processes, version control, browser, changes, suspend, resume,
47
+ * snapshot, fork, reset, save-as-template, volumes and idle policies are refused; processful workspaces have no
48
+ * executions and no tree revisions. When the handle knows the workspace's mode the SDK raises it before any request
49
+ * (`local` true); otherwise it is the server's refusal. Either way `err.reason === 'not_supported_for_mode'`.
50
+ */
51
+ export class NotSupportedForModeError extends ShardfluxApiError {
52
+ /** The workspace's mode (null when the server did not say). */
53
+ mode;
54
+ /** The refused call, e.g. `pty.open`, `suspend`, `executions.run` (null when the server did not say). */
55
+ operation;
56
+ /** True when the SDK refused the call itself, without a request. */
57
+ local;
58
+ constructor(status, body, source, retryAfterSeconds, treeRevision, local = false) {
59
+ super(status, body, source, retryAfterSeconds, treeRevision);
60
+ this.name = 'NotSupportedForModeError';
61
+ const mode = body.error.details?.mode;
62
+ const operation = body.error.details?.operation;
63
+ this.mode = mode === 'processful' || mode === 'file_first' ? mode : null;
64
+ this.operation = typeof operation === 'string' && operation !== '' ? operation : null;
65
+ this.local = local;
66
+ }
67
+ /** The SDK's own refusal of `operation` for a workspace whose mode it knows (no request was made). */
68
+ static local(mode, operation, source, message) {
69
+ return new NotSupportedForModeError(409, { error: { code: 'conflict', message, request_id: '', retryable: false, details: { reason: 'not_supported_for_mode', mode, operation } } }, source, undefined, undefined, true);
70
+ }
71
+ }
72
+ /**
73
+ * A file-first call with `ifTreeRevision` (`If-Match`) found the tree at another revision (contracts §29.8): 409
74
+ * `conflict`, details.reason `tree_revision_mismatch`. Nothing changed. `currentTreeRevision` is the revision the tree
75
+ * is at: read what changed, then retry against it.
76
+ */
77
+ export class TreeRevisionMismatchError extends ShardfluxApiError {
78
+ currentTreeRevision;
79
+ constructor(status, body, source, retryAfterSeconds, treeRevision) {
80
+ super(status, body, source, retryAfterSeconds, treeRevision);
81
+ this.name = 'TreeRevisionMismatchError';
82
+ const cur = body.error.details?.current_tree_revision;
83
+ this.currentTreeRevision = typeof cur === 'number' && Number.isSafeInteger(cur) ? cur : (treeRevision ?? null);
84
+ }
85
+ }
86
+ /**
87
+ * Builds the error for an error body, typed by its reason: NotSupportedForModeError for `not_supported_for_mode`,
88
+ * TreeRevisionMismatchError for `tree_revision_mismatch`, else ShardfluxApiError. Every SDK request builds its errors
89
+ * here, so `instanceof` works whichever call was refused.
90
+ */
91
+ export function apiError(status, body, source, retryAfterSeconds, treeRevision) {
92
+ switch (body.error.details?.reason) {
93
+ case 'not_supported_for_mode':
94
+ return new NotSupportedForModeError(status, body, source, retryAfterSeconds, treeRevision);
95
+ case 'tree_revision_mismatch':
96
+ return new TreeRevisionMismatchError(status, body, source, retryAfterSeconds, treeRevision);
97
+ default:
98
+ return new ShardfluxApiError(status, body, source, retryAfterSeconds, treeRevision);
99
+ }
100
+ }
37
101
  /** The response was not the documented shape (e.g. a proxy error page). */
38
102
  export class ShardfluxProtocolError extends Error {
39
103
  status;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Executions of file-first workspaces (contracts §29.1, §29.8): each `POST /exec` runs one command in a fresh VM on the
3
+ * workspace's latest tree revision and answers when it ended; the files it changed under /home/user become the next
4
+ * revision. The execution id is the idempotency key: a request retried with the same id returns the recorded result
5
+ * (or waits for the running execution) and never runs the command a second time.
6
+ */
7
+ import type { components } from './generated/cell-api.js';
8
+ type S = components['schemas'];
9
+ /** The cell's `ExecutionResult` as sent on the wire (stdout/stderr base64). */
10
+ export type ExecutionResultBody = S['ExecutionResult'];
11
+ /** One entry the execution added, modified or deleted under /home/user (`type` is the new entry's, or the removed one's). */
12
+ export type ExecutionChange = S['ExecutionChange'];
13
+ /** queued / running (in progress), succeeded (the command ran to its end, any exit code), failed or lost (nothing published). */
14
+ export type ExecutionState = S['ExecutionState'];
15
+ /** The `error` of a failed or lost execution: `details.reason` names the cause (e.g. `lease_expired`, `exec_failed_to_start`). */
16
+ export interface ExecutionError {
17
+ code: string;
18
+ message: string;
19
+ /** A new execution id may succeed (the SDK never retries with a new id itself). */
20
+ retryable: boolean;
21
+ details?: Record<string, unknown>;
22
+ }
23
+ /** `^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$` (cell-api.yaml ExecutionIdValue). */
24
+ export declare const EXECUTION_ID: RegExp;
25
+ /** A fresh execution id: `ex-` plus a random UUID (39 characters, valid per EXECUTION_ID). */
26
+ export declare function newExecutionId(): string;
27
+ export interface ExecutionRunOptions {
28
+ /**
29
+ * The execution's idempotency key (8-128 characters of A-Z a-z 0-9 . _ : -, starting with a letter or digit). Default:
30
+ * a fresh `ex-<uuid>`. Pass your own to make the call safe to repeat across processes: the same id with the same request
31
+ * returns the recorded result (`replayed`), with another request 409 `execution_id_reused`.
32
+ */
33
+ executionId?: string;
34
+ /** Working directory (absolute, under /home/user). */
35
+ cwd?: string;
36
+ env?: Record<string, string>;
37
+ user?: string;
38
+ /** Written to stdin, which is then closed (at most 1 MiB). */
39
+ stdin?: string | Uint8Array;
40
+ /** Kill the command after this long (`timedOut`); 0 or omitted: no limit. */
41
+ timeoutMs?: number;
42
+ killGraceMs?: number;
43
+ /** Names of customer secrets injected as environment variables of this command only (as for `exec.run`). */
44
+ secretRefs?: string[];
45
+ /** stdout and stderr are each captured up to this many bytes (1-16 MiB; cell default 1 MiB), `stdoutTruncated` beyond. */
46
+ outputLimitBytes?: number;
47
+ /**
48
+ * Retries, with the same execution id, of network failures and retryable 429/5xx answers (e.g. 503
49
+ * `no_execution_host`: no host has room; `Retry-After` is honoured up to 30 s). Default 5. Refusals such as
50
+ * `execution_id_reused` or a validation error are not retried; `workspace_busy` (another execution holds the
51
+ * workspace) is waited out within the client's `transitionTimeoutMs` like any tool call.
52
+ */
53
+ maxRetries?: number;
54
+ /**
55
+ * Longest single wait for the answer (default 300 000 ms). When it elapses the request is sent again with the same id,
56
+ * which joins the running execution; these re-attachments are not failures and never run the command again.
57
+ */
58
+ attemptTimeoutMs?: number;
59
+ /** Stops waiting (the execution continues server side; `executions.get(id)` returns its result later). */
60
+ signal?: AbortSignal;
61
+ }
62
+ export interface ExecutionGetOptions {
63
+ /**
64
+ * While the execution is queued or running (202), poll until it ended or this long passed (backoff 250 ms doubling to
65
+ * 2 s), then return the last view. Default 0: return at once (a pending result has `pending` true).
66
+ */
67
+ waitMs?: number;
68
+ signal?: AbortSignal;
69
+ }
70
+ /** The outcome of an execution: decoded output, exit status, the revision it produced and what it changed. */
71
+ export declare class ExecutionResult {
72
+ readonly executionId: string;
73
+ readonly state: ExecutionState;
74
+ /** The tree revision the command ran on. */
75
+ readonly baseRevision: number;
76
+ /**
77
+ * The tree revision after the execution: `baseRevision + 1` when it changed files, else `baseRevision`; null unless
78
+ * `state` is `succeeded` (a failed or lost execution publishes nothing).
79
+ */
80
+ readonly treeRevision: number | null;
81
+ /** The command's exit status (-1 when a signal ended it); null unless succeeded. */
82
+ readonly exitCode: number | null;
83
+ readonly termSignal: number | null;
84
+ /** The command ran past `timeoutMs` and was killed. */
85
+ readonly timedOut: boolean;
86
+ /** Standard output, at most `outputLimitBytes`. */
87
+ readonly stdout: Uint8Array;
88
+ readonly stderr: Uint8Array;
89
+ readonly stdoutTruncated: boolean;
90
+ readonly stderrTruncated: boolean;
91
+ /** What the execution changed under /home/user, sorted by path (at most 10 000; see `changedTruncated`). */
92
+ readonly changed: ExecutionChange[];
93
+ readonly changedTruncated: boolean;
94
+ /** Milliseconds and counts: queue_ms, run_ms, publish_ms, total_ms and the host's (boot_ms, exec_ms, vm_seconds, ...). */
95
+ readonly timings: Record<string, number>;
96
+ /** failed / lost: why (`details.reason`); null otherwise. */
97
+ readonly error: ExecutionError | null;
98
+ readonly createdAt: string;
99
+ readonly finishedAt: string | null;
100
+ /**
101
+ * The call that returned this result did not start the execution: `executions.run()` answered 200 (the execution
102
+ * already existed: its recorded result, or the running one waited for), or it came from `executions.get()`. False
103
+ * only for the run() call whose request started it (201).
104
+ */
105
+ readonly replayed: boolean;
106
+ /** The body as the cell sent it. */
107
+ readonly raw: ExecutionResultBody;
108
+ constructor(body: ExecutionResultBody, replayed: boolean);
109
+ /** Still queued or running (only `executions.get()` returns such a result). */
110
+ get pending(): boolean;
111
+ /** The command ran to its end with exit code 0. */
112
+ get ok(): boolean;
113
+ /** A stream as text (UTF-8; invalid sequences become U+FFFD). */
114
+ text(stream?: 'stdout' | 'stderr'): string;
115
+ get stdoutText(): string;
116
+ get stderrText(): string;
117
+ /** `error.details.reason` of a failed or lost execution (null otherwise). */
118
+ get errorReason(): string | null;
119
+ }
120
+ export {};