@shardflux/sdk 0.11.0 → 0.12.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.
@@ -170,6 +170,29 @@ export interface paths {
170
170
  patch?: never;
171
171
  trace?: never;
172
172
  };
173
+ "/v1/workspaces/{workspace_id}/exec/{session_id}/stdin": {
174
+ parameters: {
175
+ query?: never;
176
+ header?: never;
177
+ path: {
178
+ workspace_id: components["parameters"]["WorkspaceId"];
179
+ session_id: components["parameters"]["SessionId"];
180
+ };
181
+ cookie?: never;
182
+ };
183
+ get?: never;
184
+ put?: never;
185
+ /**
186
+ * Write pipe stdin or send EOF to an exec session
187
+ * @description Requires stdin_open at start (processful only), host exec_stdin and guest exec_stdin.v1. Offset is the total bytes previously accepted (initially 0). A repeated identical last frame is safe; a different stale offset is refused. Writes are bounded: offset in the response may acknowledge only a prefix; continue from that offset. close takes effect only after the whole frame is accepted. The transport writes no input log or payload file; program output and full-state snapshots retain their normal persistence. An ended session refuses input.
188
+ */
189
+ post: operations["execInput"];
190
+ delete?: never;
191
+ options?: never;
192
+ head?: never;
193
+ patch?: never;
194
+ trace?: never;
195
+ };
173
196
  "/v1/workspaces/{workspace_id}/exec/{session_id}/signal": {
174
197
  parameters: {
175
198
  query?: never;
@@ -843,6 +866,8 @@ export interface components {
843
866
  /** @description Absolute working directory; omitted or empty starts in the default (/home/user). A relative path is refused, not resolved: 422 validation_failed, details.reason invalid_cwd, details.field cwd, the message naming the absolute path it likely means. A cwd that is not a directory ends the session failed_to_start (processful) or the execution failed with details.reason exec_failed_to_start (file-first). */
844
867
  cwd?: string;
845
868
  user?: string;
869
+ /** @description Keep a pipe open for exec stdin writes; processful only, mutually exclusive with stdin. Requires exec_stdin.v1. */
870
+ stdin_open?: boolean;
846
871
  /** @description Written to stdin, which is then closed (max 1 MiB decoded). */
847
872
  stdin?: string;
848
873
  /** Format: int64 */
@@ -979,6 +1004,19 @@ export interface components {
979
1004
  };
980
1005
  /** @description Signal number (1-64) or name (e.g. "SIGTERM", "TERM"). */
981
1006
  SignalValue: number | string;
1007
+ ExecInputRequest: {
1008
+ /** @description At most 64 KiB decoded. */
1009
+ data?: string;
1010
+ /** Format: int64 */
1011
+ offset: number;
1012
+ /** @default false */
1013
+ close?: boolean;
1014
+ };
1015
+ ExecInputResult: {
1016
+ /** Format: int64 */
1017
+ offset: number;
1018
+ closed: boolean;
1019
+ };
982
1020
  SignalRequest: {
983
1021
  signal: components["schemas"]["SignalValue"];
984
1022
  /**
@@ -1607,6 +1645,34 @@ export interface operations {
1607
1645
  default: components["responses"]["Error"];
1608
1646
  };
1609
1647
  };
1648
+ execInput: {
1649
+ parameters: {
1650
+ query?: never;
1651
+ header?: never;
1652
+ path: {
1653
+ workspace_id: components["parameters"]["WorkspaceId"];
1654
+ session_id: components["parameters"]["SessionId"];
1655
+ };
1656
+ cookie?: never;
1657
+ };
1658
+ requestBody: {
1659
+ content: {
1660
+ "application/json": components["schemas"]["ExecInputRequest"];
1661
+ };
1662
+ };
1663
+ responses: {
1664
+ /** @description Acknowledged input offset and EOF state. */
1665
+ 200: {
1666
+ headers: {
1667
+ [name: string]: unknown;
1668
+ };
1669
+ content: {
1670
+ "application/json": components["schemas"]["ExecInputResult"];
1671
+ };
1672
+ };
1673
+ default: components["responses"]["Error"];
1674
+ };
1675
+ };
1610
1676
  execSignal: {
1611
1677
  parameters: {
1612
1678
  query?: never;
package/dist/http.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { RetryRecord } from './progress.js';
2
- export declare const SDK_VERSION = "0.11.0";
2
+ export declare const SDK_VERSION = "0.12.0";
3
3
  export interface RequestOptions {
4
4
  query?: Record<string, string | number | boolean | undefined | null>;
5
5
  json?: unknown;
@@ -29,17 +29,13 @@ export interface HttpOptions {
29
29
  }
30
30
  export declare const defaultSleep: (ms: number) => Promise<void>;
31
31
  /**
32
- * The fetch the SDK uses when none is given. On runtimes whose bundled undici is 8.x (Node 26) it sends
33
- * `Connection: close`: undici 8.9 intermittently stalls a request on a reused keep-alive connection after the
34
- * connection sat idle for a few seconds (measured against the staging API: 20.9 s and 26.3 s stalls in 50 requests
35
- * with 0.2-12 s gaps; Node 22 / undici 6.28 max 351 ms; Node 26 with `Connection: close` max 435 ms; node:http2 on the
36
- * same load balancer and gaps max 193 ms, and the load balancer's idle timeout is 3600 s, so it is not an idle-timeout
37
- * interaction). The CLI and MCP server do the same. Other runtimes keep the runtime's keep-alive pooling.
38
- * `SHARDFLUX_HTTP_KEEPALIVE=1` disables the workaround.
32
+ * Reuses TLS connections. Node 26's bundled undici 8.9 can stall reused connections: use pinned undici with a
33
+ * private HTTP/1.1-only dispatcher. Other runtimes retain native fetch. SHARDFLUX_HTTP_KEEPALIVE=0 forces close
34
+ * for diagnosis; =1 retains the historical opt-in to native pooling. An explicitly supplied fetch is untouched.
39
35
  */
40
36
  export declare function defaultFetch(env?: Record<string, string | undefined> | undefined, undiciVersion?: string | undefined): typeof fetch;
41
37
  export declare function buildUrl(base: string, path: string, query?: RequestOptions['query']): string;
42
- /** `X-Tree-Revision` (file-first workspaces, contracts §29.8): a non-negative integer, else null. */
38
+ /** `X-Tree-Revision` (file-first workspaces): a non-negative integer, else null. */
43
39
  export declare function treeRevisionOf(headers: Headers): number | null;
44
40
  /** Turns a non-2xx response into a ShardfluxApiError (or a protocol error for undocumented bodies). */
45
41
  export declare function errorFrom(res: Response, source: 'api' | 'cell'): Promise<Error>;
@@ -61,10 +57,10 @@ export declare class HttpClient {
61
57
  body: T;
62
58
  }>;
63
59
  }
64
- /** Longest server-side wait the SDK asks for (contracts §3: servers cap `Prefer: wait` at 20 s). */
60
+ /** Longest server-side wait the SDK asks for (servers cap `Prefer: wait` at 20 s). */
65
61
  export declare const SERVER_WAIT_MAX_S = 20;
66
62
  /**
67
- * One bounded-wait poll (contracts §3): GET `path` with `Prefer: wait=<s>` when `waitS` >= 1. `applied` is true only
63
+ * One bounded-wait poll: GET `path` with `Prefer: wait=<s>` when `waitS` >= 1. `applied` is true only
68
64
  * when the server says it waited (Preference-Applied); otherwise the caller keeps its own backoff.
69
65
  */
70
66
  export declare function pollWithWait<T>(http: HttpClient, path: string, authorization: string, waitS: number, signal?: AbortSignal, onRetry?: RequestOptions['onRetry']): Promise<{
package/dist/http.js CHANGED
@@ -8,26 +8,37 @@
8
8
  */
9
9
  import { ShardfluxApiError, ShardfluxProtocolError, apiError, isErrorBody } from "./errors.js";
10
10
  import { describeFailure } from "./progress.js";
11
- export const SDK_VERSION = '0.11.0';
11
+ export const SDK_VERSION = '0.12.0';
12
12
  export const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
13
+ /** One private HTTP/1.1 pool on Node 26+, shared by SDK clients. No global dispatcher changes. */
14
+ let nodeFetch;
13
15
  /**
14
- * The fetch the SDK uses when none is given. On runtimes whose bundled undici is 8.x (Node 26) it sends
15
- * `Connection: close`: undici 8.9 intermittently stalls a request on a reused keep-alive connection after the
16
- * connection sat idle for a few seconds (measured against the staging API: 20.9 s and 26.3 s stalls in 50 requests
17
- * with 0.2-12 s gaps; Node 22 / undici 6.28 max 351 ms; Node 26 with `Connection: close` max 435 ms; node:http2 on the
18
- * same load balancer and gaps max 193 ms, and the load balancer's idle timeout is 3600 s, so it is not an idle-timeout
19
- * interaction). The CLI and MCP server do the same. Other runtimes keep the runtime's keep-alive pooling.
20
- * `SHARDFLUX_HTTP_KEEPALIVE=1` disables the workaround.
16
+ * Reuses TLS connections. Node 26's bundled undici 8.9 can stall reused connections: use pinned undici with a
17
+ * private HTTP/1.1-only dispatcher. Other runtimes retain native fetch. SHARDFLUX_HTTP_KEEPALIVE=0 forces close
18
+ * for diagnosis; =1 retains the historical opt-in to native pooling. An explicitly supplied fetch is untouched.
21
19
  */
22
20
  export function defaultFetch(env = globalThis.process?.env, undiciVersion = globalThis.process?.versions?.undici) {
21
+ // Why a fresh connection on Node 26 (its bundled HTTP client, measured): docs/progress/startup-latency.md.
23
22
  const base = (input, init) => fetch(input, init);
24
- const major = Number((undiciVersion ?? '').split('.')[0]);
25
- if (!(major >= 8) || env?.SHARDFLUX_HTTP_KEEPALIVE === '1')
23
+ if (env?.SHARDFLUX_HTTP_KEEPALIVE === '0')
24
+ return (input, init) => {
25
+ const headers = new Headers(init?.headers);
26
+ headers.set('connection', 'close');
27
+ return base(input, { ...init, headers });
28
+ };
29
+ if (Number((undiciVersion ?? '').split('.')[0]) < 8 || !undiciVersion || env?.SHARDFLUX_HTTP_KEEPALIVE === '1')
26
30
  return base;
27
- return (input, init) => {
28
- const headers = new Headers(init?.headers);
29
- headers.set('connection', 'close');
30
- return fetch(input, { ...init, headers });
31
+ return async (input, init) => {
32
+ nodeFetch ??= import('undici').then(({ Agent, fetch: pooledFetch }) => {
33
+ const dispatcher = new Agent({ allowH2: false, pipelining: 1 });
34
+ return async (input, init) => {
35
+ const response = await pooledFetch(input, { ...init, dispatcher });
36
+ // Web-standard runtime shape; undici and DOM iterator declarations differ.
37
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- Required by consumers compiling with lib.dom instead of only Node types.
38
+ return response;
39
+ };
40
+ });
41
+ return (await nodeFetch)(input, init);
31
42
  };
32
43
  }
33
44
  export function buildUrl(base, path, query) {
@@ -45,7 +56,7 @@ function retryAfterSeconds(res) {
45
56
  const n = Number(v);
46
57
  return Number.isFinite(n) && n >= 0 ? n : undefined;
47
58
  }
48
- /** `X-Tree-Revision` (file-first workspaces, contracts §29.8): a non-negative integer, else null. */
59
+ /** `X-Tree-Revision` (file-first workspaces): a non-negative integer, else null. */
49
60
  export function treeRevisionOf(headers) {
50
61
  const v = headers.get('x-tree-revision');
51
62
  if (v === null || !/^\d{1,19}$/.test(v))
@@ -65,7 +76,7 @@ export async function errorFrom(res, source) {
65
76
  }
66
77
  if (isErrorBody(parsed))
67
78
  return apiError(res.status, parsed, source, retryAfterSeconds(res), treeRevisionOf(res.headers) ?? undefined);
68
- return new ShardfluxProtocolError(`HTTP ${res.status} from ${source === 'api' ? 'the Shardflux API' : 'the cell gateway'} without an error body`, res.status);
79
+ return new ShardfluxProtocolError(`HTTP ${res.status} from ${source === 'api' ? 'the Shardflux API' : 'the cell gateway'} without an error body`, res.status, source);
69
80
  }
70
81
  export class HttpClient {
71
82
  opts;
@@ -145,7 +156,7 @@ export class HttpClient {
145
156
  return JSON.parse(text);
146
157
  }
147
158
  catch {
148
- throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status);
159
+ throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status, this.opts.source);
149
160
  }
150
161
  }
151
162
  /** Like json() but also returns the status and the response headers (bounded waits read Preference-Applied). */
@@ -156,7 +167,7 @@ export class HttpClient {
156
167
  return { status: res.status, headers: res.headers, body: (text.length === 0 ? undefined : JSON.parse(text)) };
157
168
  }
158
169
  catch {
159
- throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status);
170
+ throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status, this.opts.source);
160
171
  }
161
172
  }
162
173
  /** Like json() but also returns the HTTP status (open() distinguishes 200 from 202). */
@@ -167,14 +178,14 @@ export class HttpClient {
167
178
  return { status: res.status, body: (text.length === 0 ? undefined : JSON.parse(text)) };
168
179
  }
169
180
  catch {
170
- throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status);
181
+ throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status, this.opts.source);
171
182
  }
172
183
  }
173
184
  }
174
- /** Longest server-side wait the SDK asks for (contracts §3: servers cap `Prefer: wait` at 20 s). */
185
+ /** Longest server-side wait the SDK asks for (servers cap `Prefer: wait` at 20 s). */
175
186
  export const SERVER_WAIT_MAX_S = 20;
176
187
  /**
177
- * One bounded-wait poll (contracts §3): GET `path` with `Prefer: wait=<s>` when `waitS` >= 1. `applied` is true only
188
+ * One bounded-wait poll: GET `path` with `Prefer: wait=<s>` when `waitS` >= 1. `applied` is true only
178
189
  * when the server says it waited (Preference-Applied); otherwise the caller keeps its own backoff.
179
190
  */
180
191
  export async function pollWithWait(http, path, authorization, waitS, signal, onRetry) {
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * const workspace = await cloud.workspaces.open({ key: `${customerId}/${projectId}`, template: 'python-node-browser' });
7
7
  * await agent.run({ input: userMessage, tools: workspaceTools(workspace) });
8
8
  *
9
- * File-first workspaces (`mode: 'file_first'`, contracts §29) keep a versioned file tree and run each command as an
9
+ * File-first workspaces (`mode: 'file_first'`) keep a versioned file tree and run each command as an
10
10
  * execution: `await workspace.executions.run(['bash', '-lc', 'pytest -q'])`.
11
11
  *
12
12
  * Types come from the committed OpenAPI documents: application API
@@ -16,9 +16,9 @@ export type { components, operations, paths } from './generated/app-api.js';
16
16
  export type { components as CellComponents, paths as CellPaths } from './generated/cell-api.js';
17
17
  export { BillingApi, Shardflux, WorkspacesApi, fetchBillingCatalog, pickByKey } from './client.js';
18
18
  export type { AgentSession, BillingCatalog, BillingSubscription, Caps, CheckoutSession, DiskLayout, Entitlements, FindByKeyOptions, ForkTarget, Invoice, InvoicePage, LifetimeFilter, ListParams, Me, OpenParams, OpenResponse, Operation, Page, PortalSession, PurposeFilter, ResetWorkspaceBody, ResumeAnswer, ResumeRequestOptions, ResumeResponse, ShardfluxOptions, SuspendRequest, SuspendWhenIdleOptions, SuspendWhenIdleResponse, SuspendWhenIdleResult, UpdatePolicy, WaitOptions, WorkspaceInputs, WorkspaceLifetime, WorkspaceOrigin, WorkspacePurpose, WorkspaceView, } from './client.js';
19
- export type { FinishedOperation, LifecycleOptions, ResumeOptions, WaitedLifecycleOptions, WaitedResumeOptions } from './lifecycle.js';
20
- export { formatTiming } from './progress.js';
21
- export type { LifecycleAction, LifecyclePhase, LifecycleTiming, ProgressEvent, ProgressListener, RetryRecord, ServerTiming, TimingOutcome, TimingPhase, } from './progress.js';
19
+ export type { FinishedOperation, LifecycleOptions, ResumeOptions, SuspendOptions, WaitedLifecycleOptions, WaitedResumeOptions, WaitedSuspendOptions } from './lifecycle.js';
20
+ export { durabilityOf, formatTiming, isDurable, lostSuspendOf } from './progress.js';
21
+ export type { Durability, LostSuspend, LifecycleAction, LifecyclePhase, LifecycleTiming, ProgressEvent, ProgressListener, RetryRecord, ServerTiming, TimingOutcome, TimingPhase, } from './progress.js';
22
22
  export { FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH } from './feedback.js';
23
23
  export type { AccountFeedbackParams, FeedbackCategory, FeedbackContext, FeedbackReceipt, SendFeedbackParams } from './feedback.js';
24
24
  export { UsageApi } from './usage.js';
@@ -50,10 +50,15 @@ export type { AnthropicToolDefinition, JsonSchema, OpenAIChatToolDefinition, Ope
50
50
  export { CaptureError, ToolCallCapture, captureTool } from './capture.js';
51
51
  export type { CallLike, CallRef, CaptureCall, CaptureErrorKind, CaptureEvent, CaptureFlushResult, CapturePart, CaptureSelector, CaptureSource, CaptureStats, CaptureStatus, DropReason, ToolCallCaptureOptions, WrapOptions, } from './capture.js';
52
52
  export type { AiSdkAdapter, AiSdkToolEndEvent, AnthropicAdapter, ClaudeAdapter, ClaudeCaptureHooks, ClaudeHookCallback, ClaudeHookMatcher, LangChainAdapter, LangChainToolHandler, MastraAdapter, MastraAfterToolCallContext, McpAdapter, OpenAIAgentsAdapter, } from './capture-adapters.js';
53
- export { ExecStartError, NotSupportedForModeError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TreeRevisionMismatchError } from './errors.js';
53
+ export { DurabilityLostError, ExecStartError, NotSupportedForModeError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TreeRevisionMismatchError } from './errors.js';
54
54
  export type { AppErrorCode, CellErrorCode, ErrorCode, ErrorReason, KnownErrorReason, WorkspaceMode } from './errors.js';
55
55
  export { SDK_VERSION } from './http.js';
56
56
  export { API_KEY_TOOL_PERMISSIONS, AccountApiKeysApi, AccountAuditApi, AccountAuthApi, AccountBillingApi, AccountExportsApi, AccountInvitationsApi, AccountMembersApi, AccountOrganizationsApi, AccountProjectsApi, AccountTemplatesApi, AccountTotpApi, AccountUserApi, CheckoutTimeoutError, OrganizationExportsApi, SESSION_TOKEN_PATTERN, ShardfluxAccount, isSessionToken, parseEmailToken, } from './account.js';
57
57
  export type { AccountClientOptions, AccountDeletion, AccountDeletionCanceled, AccountDeletionScheduled, ApiKey, ApiKeyPage, ApiKeyToolPermission, AuditExportParams, AuthSessionInfo, AuthSessionPage, AuthSessionState, AuthUser, BillingInvoicePage, BillingPortalSession, CheckoutStatus, CreateApiKeyParams, CreatedApiKey, DataExport, EmailChangeConfirmResult, EmailChangeResult, Invitation, InvitationPage, LoginResult, Member, MemberPage, MemberRole, MfaChallengeResult, Organization, OrganizationDeletion, OrganizationDeletionResult, OrganizationPage, OrganizationRole, OrganizationWorkspacePage, OrganizationWorkspacesParams, PageParams, PasswordChangeResult, PasswordResetConfirmResult, PasswordResetRequestResult, Project, ProjectPage, RecoveryCodes, RegisterResult, ResendVerificationResult, SessionTokenUpdate, ShardfluxAccountOptions, SpendPolicyUpdate, StepUpResult, TotpConfirmResult, TotpDisableResult, TotpEnrollment, VerifyEmailResult, WaitForCheckoutOptions, } from './account.js';
58
58
  export { checkClientVersion, clientVersionStatus, compareVersions, versionCheckDisabledByEnv } from './version-check.js';
59
59
  export type { CheckClientVersionOptions, ClientEcosystem, ClientVersionEntry, ClientVersionStatus, ClientVersionStatusKind, ClientVersions, VersionCheckIdentity, VersionCheckOption, } from './version-check.js';
60
+ export { isWorkspaceGone } from './errors.js';
61
+ export { defaultFetch } from './http.js';
62
+ export type { IdlePolicy } from './client.js';
63
+ export type { IdleStatus, KeepaliveResult } from './cell.js';
64
+ export type { ExecInputResult } from './cell.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { BillingApi, Shardflux, WorkspacesApi, fetchBillingCatalog, pickByKey } from "./client.js";
2
- export { formatTiming } from "./progress.js";
2
+ export { durabilityOf, formatTiming, isDurable, lostSuspendOf } from "./progress.js";
3
3
  export { FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH } from "./feedback.js";
4
4
  export { UsageApi } from "./usage.js";
5
5
  export { TemplateBuildTimeoutError, TemplateBuildsApi, TemplateDraftApi, TemplatePackagesApi, TemplateUploadError, TemplateUploadsApi, TemplateVersionTestInstancesApi, TemplateVersionsApi, TemplatesApi, buildSettled, saveAsTemplateBody, } from "./templates.js";
@@ -15,7 +15,9 @@ export { EXECUTION_ID, ExecutionResult, newExecutionId } from "./executions.js";
15
15
  export { ToolTokenManager } from "./tokens.js";
16
16
  export { ToolArgumentError, executeToolCall, toAnthropicTools, toOpenAITools, validateArgs, workspaceTools } from "./tools.js";
17
17
  export { CaptureError, ToolCallCapture, captureTool } from "./capture.js";
18
- export { ExecStartError, NotSupportedForModeError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TreeRevisionMismatchError } from "./errors.js";
18
+ export { DurabilityLostError, ExecStartError, NotSupportedForModeError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TreeRevisionMismatchError } from "./errors.js";
19
19
  export { SDK_VERSION } from "./http.js";
20
20
  export { API_KEY_TOOL_PERMISSIONS, AccountApiKeysApi, AccountAuditApi, AccountAuthApi, AccountBillingApi, AccountExportsApi, AccountInvitationsApi, AccountMembersApi, AccountOrganizationsApi, AccountProjectsApi, AccountTemplatesApi, AccountTotpApi, AccountUserApi, CheckoutTimeoutError, OrganizationExportsApi, SESSION_TOKEN_PATTERN, ShardfluxAccount, isSessionToken, parseEmailToken, } from "./account.js";
21
21
  export { checkClientVersion, clientVersionStatus, compareVersions, versionCheckDisabledByEnv } from "./version-check.js";
22
+ export { isWorkspaceGone } from "./errors.js";
23
+ export { defaultFetch } from "./http.js";
@@ -32,9 +32,25 @@ export interface LifecycleOptions {
32
32
  export type WaitedLifecycleOptions = LifecycleOptions & {
33
33
  wait: true | WaitOptions;
34
34
  };
35
+ /**
36
+ * `suspend()` options (0.12.0+). A waited suspend resolves as soon as the workspace is sealed on its host, typically in a
37
+ * few hundred ms; the finished operation's `result.durable` turns true when the copy lands in durable storage,
38
+ * typically within a second. `durable: true` resolves only then: it waits for the suspend (so it implies `wait`), then
39
+ * for `result.durable`, within the same `timeoutMs` and `signal`. It throws DurabilityLostError if the copy cannot be
40
+ * made, and OperationTimeoutError (`durable: true`) when the time runs out first; the copy continues server side.
41
+ */
42
+ export interface SuspendOptions extends LifecycleOptions {
43
+ durable?: boolean;
44
+ }
45
+ /** Suspend options that wait: `wait` or `durable`. */
46
+ export type WaitedSuspendOptions = SuspendOptions & ({
47
+ wait: true | WaitOptions;
48
+ } | {
49
+ durable: true;
50
+ });
35
51
  /**
36
52
  * `resume()` options (0.9.0). With `wait`, the server holds the resume until the workspace runs and returns a tool token
37
- * with it (contracts §22.6): `agentLabel` and `tools` choose that token (defaults: a workspace handle's own, those given
53
+ * with it: `agentLabel` and `tools` choose that token (defaults: a workspace handle's own, those given
38
54
  * to open(); `workspaces.resume(id)` otherwise the key's tools and label `default`). A handle keeps it for its `cell()`
39
55
  * clients of the same label and tools; `workspaces.resume(id)` only attributes it.
40
56
  */
@@ -50,7 +66,7 @@ export declare const TRACE: unique symbol;
50
66
  /** Internal: work a workspace handle does after the operation finished, inside the same trace (refreshing its view). */
51
67
  export declare const AFTER_WAIT: unique symbol;
52
68
  /**
53
- * Internal: a workspace handle's part in a held resume (contracts §22.6): the tool token to ask for, and how it takes the
69
+ * Internal: a workspace handle's part in a held resume: the tool token to ask for, and how it takes the
54
70
  * running workspace and that token from a 200 (instead of reading the view and fetching a token afterwards).
55
71
  */
56
72
  export declare const HELD_RESUME: unique symbol;
@@ -63,6 +79,7 @@ export type InternalWaitOptions = WaitOptions & {
63
79
  [TRACE]?: Trace;
64
80
  };
65
81
  export type InternalLifecycleOptions = ResumeOptions & {
82
+ durable?: boolean;
66
83
  [AFTER_WAIT]?: (trace: Trace, operation: Operation) => Promise<void>;
67
84
  [HELD_RESUME]?: HeldResumeTarget;
68
85
  };
package/dist/lifecycle.js CHANGED
@@ -5,7 +5,7 @@ export const TRACE = Symbol('shardflux.trace');
5
5
  /** Internal: work a workspace handle does after the operation finished, inside the same trace (refreshing its view). */
6
6
  export const AFTER_WAIT = Symbol('shardflux.afterWait');
7
7
  /**
8
- * Internal: a workspace handle's part in a held resume (contracts §22.6): the tool token to ask for, and how it takes the
8
+ * Internal: a workspace handle's part in a held resume: the tool token to ask for, and how it takes the
9
9
  * running workspace and that token from a 200 (instead of reading the view and fetching a token afterwards).
10
10
  */
11
11
  export const HELD_RESUME = Symbol('shardflux.heldResume');
@@ -19,7 +19,9 @@ export function waitOptionsOf(opts) {
19
19
  * the request, every observed state, and `AFTER_WAIT`; it ends with `done` either way.
20
20
  */
21
21
  export async function runLifecycle(ctx, kind, workspaceId, start, opts, capture = {}) {
22
- const waitOpts = waitOptionsOf(opts);
22
+ // `durable` (suspend) waits for the durable copy, so it implies `wait`.
23
+ const waitOpts = waitOptionsOf(opts) ?? (opts.durable ? {} : null);
24
+ const started = Date.now();
23
25
  const trace = new Trace(kind, combineListeners(ctx.onProgress, opts.onProgress, waitOpts?.onProgress), { workspaceId });
24
26
  return traced(trace, async () => {
25
27
  // Tool-call capture writes recorded before this call land first (snapshot, fork, suspend and close include them).
@@ -36,6 +38,11 @@ export async function runLifecycle(ctx, kind, workspaceId, start, opts, capture
36
38
  throw new OperationFailedError(operation);
37
39
  if (operation.state !== 'succeeded')
38
40
  final = await ctx.workspaces.waitForOperation(operation.id, { ...waitOpts, [TRACE]: trace });
41
+ if (opts.durable) {
42
+ // One budget for the whole call: what the suspend itself left.
43
+ const timeoutMs = Math.max(1, (waitOpts.timeoutMs ?? 300_000) - (Date.now() - started));
44
+ final = await ctx.workspaces.waitForDurable(final, { ...waitOpts, timeoutMs, [TRACE]: trace });
45
+ }
39
46
  await opts[AFTER_WAIT]?.(trace, final);
40
47
  return final;
41
48
  });
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Lifecycle timing and progress. A slow open (0.8 s one day, 34 s the next) should say where the time went without a
3
- * packet capture: waiting for a host, the cell booting or restoring the VM, the network between the caller and the API,
4
- * the first tool token, or retries.
2
+ * Lifecycle timing and progress. Every open, resume and wake says where its time went, without a packet capture: the
3
+ * queue, the cell booting or restoring the VM, the network between the caller and the API, the first tool token, or
4
+ * retries.
5
5
  *
6
6
  * A Trace follows one SDK call (open, a lifecycle call with `wait`, a wake, a token fetch). It records two clocks and
7
7
  * never mixes them:
@@ -28,8 +28,10 @@ export type LifecycleAction = 'open' | 'suspend' | 'resume' | 'snapshot' | 'fork
28
28
  * - `busy`: a tool call waiting out `workspace_busy` (only in `tool` events).
29
29
  * - `capture_flush` (0.7.0+): a lifecycle call waiting for tool-call capture writes recorded before it (only when some
30
30
  * were pending).
31
+ * - `durable` (0.12.0+): `suspend({ durable: true })` or `waitForDurable()` waiting for the durable copy after the
32
+ * operation succeeded (reason `overdue` once the result has `durability.overdue_at`).
31
33
  */
32
- export type LifecyclePhase = 'request' | 'queued' | 'capacity_pending' | 'running' | 'view' | 'token' | 'busy' | 'capture_flush';
34
+ export type LifecyclePhase = 'request' | 'queued' | 'capacity_pending' | 'running' | 'view' | 'token' | 'busy' | 'capture_flush' | 'durable';
33
35
  export interface TimingPhase {
34
36
  phase: LifecyclePhase;
35
37
  /** The server's state_reason, or why the SDK entered the phase (`held`, `initial`, `expiring`, `invalidated`). */
@@ -56,8 +58,8 @@ export interface ServerTiming {
56
58
  kind: Operation['kind'];
57
59
  state: Operation['state'];
58
60
  /**
59
- * created_at → started_at: waiting until the cell began running the operation, including any wait for a host with
60
- * capacity (started_at is set when the operation first enters `running`). Null when the API does not report
61
+ * created_at → started_at: waiting until the cell began running the operation, including any time in
62
+ * `capacity_pending` (started_at is set when the operation first enters `running`). Null when the API does not report
61
63
  * started_at or the operation has not run yet. An operation that finished without running (e.g. a failed dependency)
62
64
  * has started_at = completed_at, so all of its time shows here.
63
65
  */
@@ -71,10 +73,10 @@ export interface ServerTiming {
71
73
  /** `result.warm_fallback`: why a start that could be warm booted instead. */
72
74
  warmFallback: string | null;
73
75
  /**
74
- * `result.resume_path`: `local_cache`, `prestaged` (copied to this host ahead of the resume, contracts §23),
75
- * `download` (the checkpoint had to be fetched first), `cold_boot` (0.11.0+: no host could restore the memory
76
- * snapshot, so the checkpoint's disk was booted; see `memoryRestored`) or `reset_blank_layer` (the first start after
77
- * a reset).
76
+ * `result.resume_path`: `local_cache`, `prestaged` (copied to this host ahead of the resume),
77
+ * `download` (the checkpoint had to be fetched first), `cold_boot` (0.11.0+: the saved disk was booted after a
78
+ * platform runtime change; processes restarted; see `memoryRestored`) or `reset_blank_layer` (the first start after a
79
+ * reset).
78
80
  */
79
81
  resumePath: string | null;
80
82
  /**
@@ -89,11 +91,70 @@ export interface ServerTiming {
89
91
  * `runtime_changed` (the platform's VM runtime changed after the suspend). Null otherwise.
90
92
  */
91
93
  coldBootReason?: string | null;
94
+ /**
95
+ * `result.durable` (0.12.0+), suspend and fork: `true` when the capture is in durable storage, `false` while its
96
+ * durable copy is being written (see `durability`). `null` when the result does not say (other kinds, an older API).
97
+ */
98
+ durable?: boolean | null;
99
+ /** `result.suspend_path` (0.12.0+): `local_commit` when the suspend returned as soon as it was sealed on its host. */
100
+ suspendPath?: string | null;
101
+ /** `result.durability` (0.12.0+): the durable copy's state and timestamps. Null when the result has none. */
102
+ durability?: Durability | null;
103
+ /**
104
+ * `result.lost_suspend` (0.12.0+), resume: the workspace's latest suspend could not be kept and this resume restored the
105
+ * checkpoint before it (`restoredCheckpointId`, state as of `stateAsOf`). Null otherwise.
106
+ */
107
+ lostSuspend?: LostSuspend | null;
92
108
  /** `result.boot_to_ready_ms`: VM start until the guest agent answered. */
93
109
  bootToReadyMs: number | null;
94
110
  /** `result.host_timings_ms`: the host's own steps (restore: load, after_restore, ready, …). */
95
111
  hostTimingsMs: Record<string, number> | null;
96
112
  }
113
+ /**
114
+ * `result.durability` of a suspend or fork (0.12.0+), camelCased. A suspend returns as soon as the workspace is sealed on
115
+ * its host (`pending`); `durable` follows when the copy lands in durable storage (`durableAt`). Timestamps are RFC 3339.
116
+ */
117
+ export interface Durability {
118
+ /** `pending` (the durable copy is being written), `durable` (it committed) or `lost` (see the lifecycle reference). */
119
+ state: 'pending' | 'durable' | 'lost';
120
+ checkpointId: string | null;
121
+ generationId: string | null;
122
+ /** When the suspend (or fork) completed on its host. */
123
+ localCommitAt: string | null;
124
+ /** When the durable copy is expected by. */
125
+ durableBy: string | null;
126
+ /** When the durable copy committed (state `durable`). */
127
+ durableAt: string | null;
128
+ /** Local commit to durable commit, in milliseconds (state `durable`). */
129
+ localCommitToDurableMs: number | null;
130
+ /** Set once `durableBy` passed while the copy was still being written; it keeps going. */
131
+ overdueAt: string | null;
132
+ /** State `lost`: why (e.g. `host_lost`). */
133
+ reason: string | null;
134
+ }
135
+ /** `result.lost_suspend` of a resume (0.12.0+), camelCased. See the lifecycle reference. */
136
+ export interface LostSuspend {
137
+ checkpointId: string;
138
+ generationId: string | null;
139
+ reason: string | null;
140
+ suspendedAt: string | null;
141
+ /** The earlier checkpoint this resume restored. */
142
+ restoredCheckpointId: string | null;
143
+ /** The restored state is as of this time (RFC 3339). */
144
+ stateAsOf: string | null;
145
+ }
146
+ /**
147
+ * The durable copy of a suspend or fork operation (0.12.0+): `result.durability` camelCased, or null when the result has
148
+ * none (the suspend was stored durably before it completed, or another kind).
149
+ */
150
+ export declare function durabilityOf(op: Pick<Operation, 'result'>): Durability | null;
151
+ /** A resume operation's `result.lost_suspend` camelCased (0.12.0+), or null. */
152
+ export declare function lostSuspendOf(op: Pick<Operation, 'result'>): LostSuspend | null;
153
+ /**
154
+ * Whether a suspend or fork operation's capture is in durable storage (0.12.0+): `result.durable`, else `true` for a
155
+ * succeeded suspend or fork whose result predates the field, else null.
156
+ */
157
+ export declare function isDurable(op: Pick<Operation, 'result' | 'kind' | 'state'>): boolean | null;
97
158
  export type TimingOutcome = 'succeeded' | 'failed' | 'timed_out' | 'error';
98
159
  export interface LifecycleTiming {
99
160
  action: LifecycleAction;
@@ -123,8 +184,8 @@ interface EventBase {
123
184
  }
124
185
  /**
125
186
  * - `phase`: a phase began (live progress: "capacity_pending: no_ready_host"). `tool` phases come from tool calls.
126
- * A `capacity_pending` phase carries `deadlineAt` (0.6.2+, when the API reports it): when the start gives up waiting
127
- * for a host and fails with `capacity_unavailable` (retryable; nothing was started).
187
+ * A `capacity_pending` phase carries `deadlineAt` (0.6.2+, when the API reports it): the start's deadline, past which
188
+ * it fails with `capacity_unavailable` (retryable; nothing was started).
128
189
  * - `retry`: a request is retried after a transient failure (also emitted for tool calls, action `tool`).
129
190
  * - `done`: the call ended, successfully or not, with its full timing.
130
191
  */
@@ -146,9 +207,9 @@ export declare function emitTo(listeners: ReadonlyArray<ProgressListener | undef
146
207
  /** Combines listeners (client-level and per call) into one; undefined when there are none. */
147
208
  export declare function combineListeners(...listeners: Array<ProgressListener | undefined>): ProgressListener | undefined;
148
209
  /**
149
- * When a start waiting in `capacity_pending` gives up: the operation's `error.details.deadline_at` (RFC 3339). A start no
150
- * host admits by then fails with `capacity_unavailable` (retryable; nothing was started). Null in any other state, or
151
- * from an API that does not report it.
210
+ * The deadline of a start in `capacity_pending`: the operation's `error.details.deadline_at` (RFC 3339). A start still
211
+ * queued then fails with `capacity_unavailable` (retryable; nothing was started). Null in any other state, or from an
212
+ * API that does not report it.
152
213
  */
153
214
  export declare function capacityDeadlineOf(op: Operation): string | null;
154
215
  /** Server timing from an operation as GET /v1/operations/{id} returns it. */
@@ -169,7 +230,7 @@ export declare class Trace {
169
230
  get finished(): LifecycleTiming | null;
170
231
  /**
171
232
  * Enters a sequential phase (closing the current one). The same phase and reason again is not a new phase.
172
- * `deadlineAt`: when a `capacity_pending` start gives up (added to the event only).
233
+ * `deadlineAt`: the deadline of a `capacity_pending` start (added to the event only).
173
234
  */
174
235
  phase(phase: LifecyclePhase, reason?: string | null, deadlineAt?: string | null): void;
175
236
  /** Runs `fn` as a phase that may overlap others (open() reads the view and issues the token together). */
@@ -185,14 +246,14 @@ export declare class Trace {
185
246
  /** Runs `fn` under `trace` and ends the trace either way (the error keeps its timing). */
186
247
  export declare function traced<T>(trace: Trace, fn: () => Promise<T>): Promise<T>;
187
248
  /**
188
- * A human-readable account of a timing, for logs and bug reports:
249
+ * A human-readable account of a timing, for logs and bug reports. A resume in production:
189
250
  *
190
- * open 34.18 s, succeeded (workspace <id>, operation <id>)
191
- * client: request 20.01 s (held) → capacity_pending 13.52 s (no_ready_host) → running 590 ms → view 42 ms ∥ token 61 ms
192
- * server: queued 33.40 s, ran 620 ms, total 34.02 s; start warm, boot to ready 79 ms
193
- * outside the server: 161 ms
194
- * retries: 1 (POST /v1/workspaces/open, HTTP 503 unavailable, after 200 ms)
251
+ * resume 413 ms, succeeded (workspace <id>, operation <id>)
252
+ * client: request 218 ms → queued 195 ms
253
+ * server: queued 51 ms, ran 290 ms, total 341 ms; resume from local_cache, boot to ready 211 ms, host disk 0 ms, load 6 ms, ready 62 ms, total 211 ms, after_restore 36 ms
254
+ * outside the server: 72 ms
195
255
  *
256
+ * A call that retried a request adds `retries: <n> (<request>, <cause>, after <delay>)`.
196
257
  * A resume that booted the workspace instead of restoring its memory (0.11.0+) says so in the server line:
197
258
  * `resume from cold_boot: processes restarted (runtime_changed)`.
198
259
  */