@shardflux/sdk 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,73 @@
1
+ # Changelog
2
+
3
+ Every API the README shows is available from the version named here. Below 1.0, a minor release may break
4
+ compatibility; breaking changes are marked **Breaking**.
5
+
6
+ ## 0.6.1 (not yet published; npm `latest` is 0.6.0)
7
+
8
+ - `formatTiming()` joined two phases that followed each other with `∥` (ran together) when their 0.1 ms times summed
9
+ with a floating-point error (1000.2 + 300.1 > 1300.3); it now prints `→`.
10
+ - `open()`: the wait's `signal` also aborts the held request (up to 20 s); before, it only ended the polls after it,
11
+ and an abort during the held request was retried as a network failure.
12
+ - `waitForOperation()` on its own records its first poll as a `request` phase; before, a held first poll's time
13
+ belonged to no phase (a 1 s wait could show `queued 0 ms`).
14
+ - README: the timing example is the formatter's real output (durations under a second print in ms).
15
+
16
+ ## 0.6.0 (2026-09-28)
17
+
18
+ ### Lifecycle calls: requested or finished
19
+
20
+ - `suspend`, `resume`, `snapshot`, `fork`, `delete`, `reset` and `close` take `wait`. Without it they resolve when the
21
+ change is **requested** (the operation is usually still `queued`), as before. With `{ wait: true }` (or
22
+ `WaitOptions`) they resolve when it has **finished**: they return the succeeded operation (typed
23
+ `FinishedOperation`) and, on a workspace handle, refresh it, so `workspace.state` is `suspended` after
24
+ `await workspace.suspend({ wait: true })`. A failed operation throws `OperationFailedError`; running out of time
25
+ throws `OperationTimeoutError` (the operation continues).
26
+
27
+ ### Lifecycle timing and progress
28
+
29
+ - `onProgress` on `new Shardflux()`, `workspaces.open()`, `waitForOperation()`, `wake()`, every lifecycle call and
30
+ `workspace.cell()`. Events: `phase` (the request, each observed operation state with the server's reason such as
31
+ `capacity_pending` / `no_ready_host`, the view read, the first tool token), `retry` (a transient failure retried,
32
+ with its cause and backoff), and `done` with the call's `LifecycleTiming`.
33
+ - `LifecycleTiming` separates the client's phases from the operation's own server timing (`queuedMs`, `runMs`,
34
+ start or resume path, boot to ready, host restore steps) and reports `outsideServerMs`: time spent outside the
35
+ operation (network, TLS, polling, view and token).
36
+ - `workspace.lastTiming` (open, wake, waited lifecycle calls); `timing` on `OperationTimeoutError`,
37
+ `OperationFailedError` and `ShardfluxApiError` when a traced call fails; `formatTiming()` prints a timing.
38
+ - Tool token fetches are traced (action `token`, reason `initial`, `expiring` or `invalidated`), and tool calls report
39
+ `workspace_busy` waits, replaced stale tokens and retries (action `tool`).
40
+ - Needs an API that reports `started_at` on operations for `queuedMs` / `runMs`; with an older API they are null.
41
+
42
+ ### Faster opens and waits
43
+
44
+ - `open()` holds the request on the server until the workspace is ready (`Prefer: wait`, up to 20 s per request) and
45
+ receives the first tool token in the same response; it pre-connects to the workspace's cell meanwhile.
46
+ - `waitForOperation()` and `templates.builds.waitForBuild()` use server-held polls (at most 20 s per request) and
47
+ fall back to backoff (250 ms doubling to 5 s, ±20 % jitter) against a server without them.
48
+ - On Node 26 the default `fetch` sends `Connection: close` (its bundled undici 8 can stall a request on a reused
49
+ keep-alive connection for tens of seconds). `SHARDFLUX_HTTP_KEEPALIVE=1` or your own `fetch` changes that.
50
+
51
+ ### Suspended workspaces wake on use
52
+
53
+ - A tool call on a suspended workspace resumes it (or joins the resume or open already running) and then runs,
54
+ bounded by `transitionTimeoutMs` (default 120 000 ms); calls during a suspend or resume wait for it.
55
+ `workspace.wake()` does the same on demand; `workspace.cell({ wake: null })` opts out.
56
+
57
+ ### Workspaces
58
+
59
+ - Secret bindings: `open({ secrets })`, `workspace.secrets.get()/set()`; `cloud.secrets` gained
60
+ `createOrganization`, `listOrganization` and `accessEvents`.
61
+ - Sessions: `open({ lifetime: 'session' })`, `workspace.close()`, `idleTimeoutSeconds`, `endedReason`.
62
+ - Layered workspaces: `workspace.reset()`, `saveAsTemplate()`, `changes()`, `diskLayout`, `origin`.
63
+ - `workspaces.findByKey()` (live workspace first, then tombstones), `list({ lifetime, purpose })`, `pickByKey()`.
64
+ - Templates: `templates.files()`, `fileEntry()`, `diff()`, `diffAll()`, and dev mode (`templates.draft(slug)`:
65
+ create, capture state, test instances, publish, discard).
66
+ - `ShardfluxApiError.reason` (`details.reason`), with `KnownErrorReason`.
67
+
68
+ ## 0.5.0 (2026-09-26)
69
+
70
+ First public release: `Shardflux` client, `workspaces.open/get/list/listAll`, lifecycle calls returning operations,
71
+ `waitForOperation()`, the cell client (`exec`, `files`, `pty`, `processes`, `git`, `browser`), `workspaceTools()` with
72
+ `toOpenAITools()` / `toAnthropicTools()` / `executeToolCall()`, secrets, egress, usage, billing, volumes, templates and
73
+ custom template builds.
package/README.md CHANGED
@@ -9,6 +9,10 @@ tools (exec, files, processes, PTY, git, browser) that plug into any model provi
9
9
  > **Early access.** Shardflux is in early access. The API is versioned (`/v1`), but this SDK is
10
10
  > below 1.0: a minor release may contain breaking changes (see [Compatibility](#compatibility)).
11
11
 
12
+ > **Versions.** This README describes 0.6.1. Anything marked **(0.6.0+)** is not in 0.5.0;
13
+ > [CHANGELOG.md](./CHANGELOG.md) lists what each version added. Check yours with
14
+ > `npm ls @shardflux/sdk` or the exported `SDK_VERSION`.
15
+
12
16
  - ESM only, no runtime dependencies, Node.js 24 or later.
13
17
  - Typed from the published OpenAPI documents.
14
18
  - Retries, idempotency keys, operation polling and tool-token refresh are handled for you.
@@ -25,23 +29,33 @@ Create a project API key in the Shardflux console (`sfk_<key id>_<secret>`) and
25
29
  server. API keys are server credentials: never put one in a browser bundle.
26
30
 
27
31
  ```ts
28
- import { Shardflux } from '@shardflux/sdk';
32
+ import { Shardflux, formatTiming } from '@shardflux/sdk';
29
33
 
30
34
  const cloud = new Shardflux({ apiKey: process.env.SHARDFLUX_API_KEY! });
31
-
32
- // Creates the workspace on first use; afterwards reconnects (or resumes) the same one.
33
- const workspace = await cloud.workspaces.open({ key: 'customer-42/main', template: 'python-node-browser' });
34
-
35
- const cell = workspace.cell();
36
- const result = await cell.exec.run(['python3', '-c', 'print(40 + 2)']);
37
- console.log(result.exitCode, result.stdout); // 0 "42\n"
38
-
39
- await cell.files.write('/home/user/notes.txt', 'hello from the SDK\n');
40
- console.log(await cell.files.readText('/home/user/notes.txt'));
35
+ const open = () => cloud.workspaces.open({ key: 'customer-42/main', template: 'python-node-browser' });
36
+
37
+ // 1. Open the workspace (created on first use) and run a command. Check that it worked.
38
+ const workspace = await open();
39
+ const run = await workspace.cell().exec.run(['python3', '-c', 'print(40 + 2)']);
40
+ if (run.exitCode !== 0) throw new Error(`python3 exited ${run.exitCode}: ${run.stderr}`);
41
+ console.log(run.stdout.trim()); // 42
42
+
43
+ // 2. Write a file, then suspend the workspace and wait until the suspend has finished.
44
+ await workspace.cell().files.write('/home/user/notes.txt', 'hello from the SDK\n');
45
+ await workspace.suspend({ wait: true }); // (0.6.0+) resolves once suspended
46
+
47
+ // 3. Open the same key again: the workspace resumes, and the file is still there.
48
+ const again = await open();
49
+ console.log(await again.cell().files.readText('/home/user/notes.txt')); // hello from the SDK
50
+ console.log(formatTiming(again.lastTiming!)); // (0.6.0+) where the resume's time went
41
51
  ```
42
52
 
43
53
  `open()` waits until the workspace is running. Opening the same key again never resets it: files,
44
- installed packages and running processes are still there.
54
+ installed packages and running processes are still there. The same program is in
55
+ [`examples/quickstart.ts`](./examples/quickstart.ts).
56
+
57
+ With 0.5.0, wait for the suspend by its operation instead:
58
+ `await cloud.workspaces.waitForOperation((await workspace.suspend()).id)`.
45
59
 
46
60
  ## Configuration
47
61
 
@@ -90,19 +104,56 @@ The same client has `exec.start/get/output/signal/cancel`, `pty`, `processes`, `
90
104
  ## Lifecycle
91
105
 
92
106
  ```ts
93
- await workspace.suspend(); // memory and processes are checkpointed
94
- await workspace.resume(); // or simply open() the key again
107
+ await workspace.suspend({ wait: true }); // memory and processes are checkpointed; resolves once suspended
108
+ await workspace.resume({ wait: true }); // or simply open() the key again
95
109
 
96
- const { operation, workspace: copy } = await workspace.fork({ key: 'customer-42/experiment' });
97
- await cloud.workspaces.waitForOperation(operation.id);
110
+ const { workspace: copy } = await workspace.fork({ key: 'customer-42/experiment' }, { wait: true });
98
111
 
99
112
  await copy.delete(); // tool access ends immediately; keys are never reused
100
113
  ```
101
114
 
115
+ ### Requested or finished
116
+
117
+ `suspend`, `resume`, `snapshot`, `fork`, `delete`, `reset` and `close` start a lifecycle operation. What the promise
118
+ means depends on `wait`:
119
+
120
+ | Call | Resolves when | Returns |
121
+ | --- | --- | --- |
122
+ | `await workspace.suspend()` | the suspend is **requested** (usually `queued`; the workspace is still running) | the operation (`Operation`) |
123
+ | `await workspace.suspend({ wait: true })` **(0.6.0+)** | the suspend has **finished** (`workspace.state` is then `suspended`) | the succeeded operation (`FinishedOperation`) |
124
+
125
+ `wait` also takes `WaitOptions` (`timeoutMs`, default 5 minutes; `signal`; `onProgress`). A failed operation throws
126
+ `OperationFailedError`. Running out of time throws `OperationTimeoutError`, and the operation continues server side:
127
+ wait again with `cloud.workspaces.waitForOperation(err.operationId)`. Without `wait`, the returned operation is the
128
+ handle for the work in progress: pass its `id` to `waitForOperation()` when you need it finished.
129
+
130
+ **Suspended workspaces wake on use.** A tool call on a suspended workspace resumes it (or joins the resume or open
131
+ already running), then runs. A call made during a suspend or resume waits for the transition to finish. The call
132
+ never runs twice: the cell executes nothing it refused.
133
+
134
+ - The wait is bounded per call by `transitionTimeoutMs` (default 120 000 ms), shared by the transition waits and at
135
+ most 3 wakes. A resume or open still pending at the end throws `OperationTimeoutError`, naming the operation, its
136
+ state and reason. A failed resume or open throws `OperationFailedError` at once.
137
+ - Following an exec's output never wakes a workspace, so an explicit `suspend()` is respected.
138
+ - `workspace.wake({ timeoutMs })` does the same on demand.
139
+ - `workspace.cell({ wake: null })` returns `workspace_not_running` instead.
140
+
141
+ ```ts
142
+ const cell = workspace.cell({ transitionTimeoutMs: 30_000 }); // give up waking after 30 s
143
+ await cell.exec.run(['make', 'test']); // resumes the workspace first if it is suspended
144
+ ```
145
+
102
146
  `suspend`, `resume`, `fork`, `snapshot` and `delete` return the lifecycle operation.
103
- `cloud.workspaces.waitForOperation(id)` polls it with backoff (250 ms doubling to 5 s, ±20 %
104
- jitter, default timeout 5 minutes). If the timeout passes, it throws `OperationTimeoutError`
105
- and the operation keeps running server side; wait for it again with the same call.
147
+ `cloud.workspaces.waitForOperation(id)` waits for it (default timeout 5 minutes): each poll asks the API to hold
148
+ the response until the operation changes (`Prefer: wait`, at most 20 s per request), so completion arrives within
149
+ one round trip of the commit. Against an API without bounded waits (or with `serverWait: false`) it polls with
150
+ backoff (250 ms doubling to 5 s, ±20 % jitter). If the timeout passes, it throws `OperationTimeoutError` and the
151
+ operation keeps running server side; wait for it again with the same call. `templates.builds.waitForBuild()` waits
152
+ the same way. A waited `open()` issues the first tool token together with the final workspace read, so the first
153
+ tool call starts at once.
154
+
155
+ On Node 26 the default fetch sends `Connection: close`: its bundled undici 8 can stall a request on a reused
156
+ keep-alive connection for tens of seconds. Pass your own `fetch`, or set `SHARDFLUX_HTTP_KEEPALIVE=1`, to change that.
106
157
 
107
158
  List and look up workspaces:
108
159
 
@@ -110,8 +161,128 @@ List and look up workspaces:
110
161
  const page = await cloud.workspaces.list({ keyPrefix: 'customer-42/' });
111
162
  for await (const ws of cloud.workspaces.listAll()) console.log(ws.key, ws.state);
112
163
  const same = await cloud.workspaces.get(workspace.id);
164
+ const byKey = await cloud.workspaces.findByKey('customer-42/main'); // null when no workspace has the key
113
165
  ```
114
166
 
167
+ `list()` shows persistent standard workspaces by default. Pass `lifetime`
168
+ (`persistent` | `session` | `any`) and `purpose` (`standard` | `template_draft` | `template_test` | `any`) to see
169
+ sessions, template drafts and test instances. `findByKey()` searches every lifetime and purpose. It returns the live
170
+ workspace, and a tombstone only when no live workspace has the key. Use it rather than `listAll({ keyPrefix })` for
171
+ lookups by key.
172
+
173
+ ### Timing and progress (0.6.0+)
174
+
175
+ Every open, wake and waited lifecycle call is traced. `workspace.lastTiming` (and `err.timing` when the call fails)
176
+ says where the time went, and `formatTiming()` prints it:
177
+
178
+ ```ts
179
+ const ws = await cloud.workspaces.open({ key: 'customer-42/main', template: 'python-node-browser' });
180
+ console.log(formatTiming(ws.lastTiming!));
181
+ ```
182
+
183
+ A slow open (34 s instead of the usual second) then reads, for example:
184
+
185
+ ```text
186
+ open 34.18 s, succeeded (workspace 01a0e5a8-3edd-74ba-b489-d62b8925e342, operation 01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33)
187
+ client: request 20.01 s (held) → capacity_pending 13.52 s (no_ready_host) → running 590 ms → view 42 ms ∥ token 61 ms
188
+ server: queued 33.40 s, ran 620 ms, total 34.02 s; start warm, boot to ready 79 ms
189
+ outside the server: 161 ms
190
+ ```
191
+
192
+ Here the time went to waiting for a host with capacity; the network and the VM start were fast.
193
+
194
+ - **client** phases are on your monotonic clock: the request (a held open waits on the server, `held`), then each
195
+ operation state the SDK observed while waiting, with the server's reason (`capacity_pending` / `no_ready_host`:
196
+ waiting for a host; `running` / `template_downloading`: fetching the template), then reading the workspace and
197
+ issuing the first tool token (together).
198
+ - **server** timing comes from the operation itself (one database clock): `queued` is creation until it began
199
+ running, including any wait for capacity; `ran` is the cell's work (placement, boot or restore, guest readiness).
200
+ `start` / `resume from` and `boot to ready` / `host …` are what the cell reported.
201
+ - **outside the server** is your total minus the operation's: network, TLS, polling latency, view and token. A large
202
+ value with a small server total points at the connection between you and the API, not at the workspace.
203
+ - **retries** lists transient failures the SDK retried (cause and backoff).
204
+
205
+ Watch it live with `onProgress` (on the client for every call, or per call):
206
+
207
+ ```ts
208
+ const cloud = new Shardflux({
209
+ apiKey: process.env.SHARDFLUX_API_KEY!,
210
+ onProgress: (e) => {
211
+ if (e.type === 'phase') console.error(`${e.action}: ${e.phase}${e.reason ? ` (${e.reason})` : ''} at ${e.atMs} ms`);
212
+ if (e.type === 'retry') console.error(`${e.action}: retry ${e.retry.request}: ${e.retry.cause}`);
213
+ if (e.type === 'done') console.error(formatTiming(e.timing));
214
+ },
215
+ });
216
+ ```
217
+
218
+ Events are `phase` (a phase began), `retry` and `done` (with the `LifecycleTiming`). Tool calls add `token` traces
219
+ (a tool token fetched: `initial`, `expiring` or `invalidated`) and `tool` events (a `busy` wait, a stale token
220
+ replaced, a retry). `queued`/`ran` need an API that reports the operation's `started_at`; otherwise they are null.
221
+
222
+ ### Sessions
223
+
224
+ A session workspace is discarded when its session ends. The session ends on `close()`, or after it has been idle for
225
+ the idle timeout (10 minutes unless the template sets one). The key then opens a new, empty workspace with a new id.
226
+
227
+ ```ts
228
+ const ws = await cloud.workspaces.open({ key: `job-${jobId}`, template: 'python-node-browser', lifetime: 'session' });
229
+ try {
230
+ await ws.cell().exec.run(['python3', 'job.py']);
231
+ } finally {
232
+ await ws.close(); // sessions: ends it (returns the delete operation); persistent: no request, returns null
233
+ }
234
+ ```
235
+
236
+ - `close()` is safe in `finally` for any workspace. It always aborts the handle's local streams (exec output, PTY
237
+ reads and in-flight cell requests); commands keep running. Only for a session does it call
238
+ `POST /v1/workspaces/{id}/close`.
239
+ - Disconnecting, a closed WebSocket or an expired token never ends a session.
240
+ - A session cannot be suspended (409, `details.reason` `session_lifetime`). Fork it to keep its state. `fork()`
241
+ takes its own `lifetime` (default `persistent`).
242
+ - Reopening a live key with a different `lifetime` is 409 `lifetime_mismatch`.
243
+
244
+ ### Reset, save as template, changes (layered workspaces)
245
+
246
+ ```ts
247
+ await ws.reset(); // wipes every change; restarts on the template (7-day recovery point)
248
+ const { operation, build } = await ws.saveAsTemplate({ templateSlug: 'acme-dev', description: 'deps installed' });
249
+ await cloud.templates.builds.waitForBuild(build.organization_id, build.id);
250
+ const page = await ws.changes({ pathPrefix: '/home/user', summary: true }); // added | modified | metadata | deleted | replaced
251
+ ```
252
+
253
+ `ws.diskLayout` says whether a workspace is `layered`. A legacy workspace is refused with 409
254
+ `legacy_disk_layout`. `changes()` is served by the cell from the running workspace and needs the `files` tool.
255
+ `cell().changesAll()` follows the pages.
256
+
257
+ ## Templates: file tree, diff and dev mode
258
+
259
+ ```ts
260
+ const dir = await cloud.templates.files('python-node-browser', 5, { path: '/usr/local/bin' });
261
+ const entry = await cloud.templates.fileEntry('acme-dev', 3, '/home/user/.bashrc');
262
+ const diff = await cloud.templates.diff('acme-dev', { from: 2, to: 3 }); // diff.summary on the first page; from: 'base' too
263
+ for await (const d of cloud.templates.diffAll('acme-dev', { from: 2, to: 3 })) console.log(d.change, d.path);
264
+ ```
265
+
266
+ Versions published before file lists answer 409 `file_list_unavailable`. A version that is still being indexed
267
+ answers `file_list_indexing` (retryable).
268
+
269
+ Develop an organization template in a draft: a layered workspace you edit live.
270
+
271
+ ```ts
272
+ const draft = cloud.templates.draft('acme-dev');
273
+ const { workspace } = await draft.create({ base: 'python-node-browser@5' }); // one live draft per template
274
+ await workspace.cell().exec.run(['bash', '-lc', 'npm ci']);
275
+ await draft.captureState({ label: 'deps installed' });
276
+ const test = await draft.openTestInstance(); // a session on a copy of the state; the draft never sees its writes
277
+ await test.close();
278
+ const { build } = await draft.publish({ description: 'npm ci' }); // 409 draft_stale if the template moved on
279
+ await draft.discard(); // deletes the draft and ends its test instances
280
+ ```
281
+
282
+ `draft.get()` throws 404 `draft_not_found` when there is no draft. `draft.find()` returns null instead.
283
+ `states()`, `statesAll()` and `testInstances({ includeEnded })` list the draft's states and test instances. Only
284
+ owners, admins and API keys with a tool permission may change drafts; others get 403 `template_dev_mode_role`.
285
+
115
286
  ## Agent tools
116
287
 
117
288
  `workspaceTools(workspace)` returns tools with a name, a description, a JSON Schema for the
@@ -131,21 +302,52 @@ const output = await executeToolCall(tools, { name: call.name, input: call.input
131
302
  Your agent loop and model calls stay in your application; the workspace is the computer the tools
132
303
  act on.
133
304
 
305
+ ## Secrets
306
+
307
+ Store credentials once and give them to a workspace's processes as environment variables. Values
308
+ are write-only: no API returns them. Every exec and terminal in the workspace receives the secrets
309
+ bound to it, plus any the call names in `secretRefs`.
310
+
311
+ ```ts
312
+ const project = (await cloud.me()).api_key!.project_id;
313
+ await cloud.secrets.create(project, { name: 'OPENAI_API_KEY', value: process.env.OPENAI_API_KEY! });
314
+
315
+ // Bind by name when opening (a new key gets the binding; an existing key has it replaced).
316
+ const workspace = await cloud.workspaces.open({ key: 'customer-42/main', template: 'python-node-browser', secrets: ['OPENAI_API_KEY'] });
317
+
318
+ await workspace.secrets.get(); // { names, secrets: [{ name, status, ... }] }
319
+ await workspace.secrets.set(['OPENAI_API_KEY', 'DATABASE_URL']); // replace; [] clears
320
+ await workspace.cell().exec.run(['python3', 'agent.py']); // sees $OPENAI_API_KEY and $DATABASE_URL
321
+ ```
322
+
323
+ - A name that is unknown, or a secret this workspace may not use, is refused with
324
+ `ShardfluxApiError` 422 (`details.reason` `secret_not_available`, `details.names`); nothing
325
+ changes. A bound secret must allow the `exec` and `pty` tools (the default).
326
+ - `status` per bound name: `available`, `not_allowed` (its permissions no longer cover this
327
+ workspace; starts are refused with 403 `secret_not_available` until fixed) or `deleted`.
328
+ - Deleting a secret removes it from every binding. Forks keep the binding, but secrets limited to
329
+ specific workspaces are checked against the fork's own id.
330
+ - A bound name that is also passed in `env` is refused (422).
331
+ - `cloud.secrets` also has `list`, `get`, `update`, `rotate`, `versions`, `delete`,
332
+ `accessEvents`, `createOrganization` and `listOrganization`. Organization-wide secrets and access
333
+ logs belong to organization owners and admins, so a project API key gets 403 for those.
334
+
134
335
  ## Errors
135
336
 
136
337
  - `ShardfluxApiError`: the API or the workspace refused the request. Fields: `status`, `code`,
137
- `message`, `requestId`, `retryable`, `details`, `operationId`, `retryAfterSeconds`.
338
+ `message`, `requestId`, `retryable`, `details`, `operationId`, `retryAfterSeconds`, and `reason`
339
+ (`details.reason`, e.g. `not_session`, `draft_not_found`, `legacy_disk_layout`; see `KnownErrorReason`).
138
340
  - `OperationFailedError`: an awaited operation ended `failed` or `canceled` (`errorCode`,
139
- `operation`).
140
- - `OperationTimeoutError`: waiting gave up; the operation continues (`operationId`).
341
+ `operation`, `timing`).
342
+ - `OperationTimeoutError`: waiting gave up; the operation continues (`operationId`, `timing`).
141
343
  - `ShardfluxProtocolError`: a response was not the documented shape.
142
344
 
143
- Treat unknown error codes as generic errors: show `message`, and use `retryable`.
345
+ Treat unknown error codes and reasons as generic errors: show `message`, and use `retryable`.
144
346
 
145
347
  ## More of the API
146
348
 
147
349
  The `Shardflux` object also has `templates` (including custom template builds), `volumes`
148
- (shared persistent storage attached to workspaces), `secrets`, `egress` (outbound allowlists),
350
+ (shared persistent storage attached to workspaces), `secrets` (see above), `egress` (outbound allowlists),
149
351
  `usage`, `billing`, `me()`, `entitlements(orgId)` and `request(method, path)` for any `/v1`
150
352
  route. The package exports the OpenAPI-generated types as well (`paths`, `components`,
151
353
  `WorkspaceView`, `Operation` and more).
@@ -156,6 +358,8 @@ route. The package exports the OpenAPI-generated types as well (`paths`, `compon
156
358
  any release; ignore unknown fields.
157
359
  - While below 1.0, a breaking change bumps the minor version (0.5 to 0.6).
158
360
  - `SDK_VERSION` is exported; requests send `User-Agent: shardflux-sdk-ts/<version>`.
361
+ - Examples in this README, in `examples/` and on shardflux.dev name the version they need. The examples on the
362
+ website and in the console are checked against the version published on npm before they ship.
159
363
 
160
364
  ## License
161
365
 
package/dist/cell.d.ts CHANGED
@@ -7,6 +7,9 @@
7
7
  * (token expired or revoked early) invalidates the token and retries once with
8
8
  * a fresh one; the retried request is safe because the gateway rejected the
9
9
  * first before any effect (and exec/PTY starts are idempotent by session_id).
10
+ * The same holds for lifecycle refusals (contracts §20.4): `workspace_busy` is
11
+ * waited out and `workspace_not_running` wakes the workspace, then the call is
12
+ * retried, all within one bounded transition budget per call.
10
13
  *
11
14
  * Exec output is retained in the guest and addressed by byte offsets, so
12
15
  * `exec.run()` survives gateway restarts/disconnects by reconnecting with the
@@ -14,6 +17,7 @@
14
17
  */
15
18
  import type { components, paths } from './generated/cell-api.js';
16
19
  import type { RequestOptions } from './http.js';
20
+ import type { ProgressListener } from './progress.js';
17
21
  import type { ToolTokenManager } from './tokens.js';
18
22
  type S = components['schemas'];
19
23
  export type ExecStartRequest = S['ExecStartRequest'];
@@ -34,6 +38,24 @@ export type BrowserScreenshotRequest = S['BrowserScreenshotRequest'];
34
38
  export type BrowserContentRequest = S['BrowserContentRequest'];
35
39
  export type BrowserContent = S['BrowserContent'];
36
40
  export type Signal = S['SignalValue'];
41
+ /** One entry of a workspace's changes against its template (contracts §19.10). */
42
+ export type WorkspaceChange = S['WorkspaceChange'];
43
+ export type WorkspaceChangeKind = WorkspaceChange['change'];
44
+ export type WorkspaceChangesSummary = S['WorkspaceChangesSummary'];
45
+ /** One page of changes in raw path-byte order; `summary` only when requested. */
46
+ export type WorkspaceChangesPage = S['WorkspaceChangesPage'];
47
+ export interface WorkspaceChangesParams {
48
+ /** Absolute path; only entries at or below it (default `/`). */
49
+ pathPrefix?: string;
50
+ /** 1..1000 (gateway default 1000). */
51
+ limit?: number;
52
+ /** `next_cursor` of the previous page. */
53
+ cursor?: string;
54
+ /** Hash regular files of 16 MiB or less (reports `metadata` when content equals the template's). */
55
+ hash?: boolean;
56
+ /** Also return totals over everything under pathPrefix. */
57
+ summary?: boolean;
58
+ }
37
59
  type CellPath = keyof paths;
38
60
  /** Fills a path template that must exist in cell-api.yaml. */
39
61
  export declare function cellPath<P extends CellPath>(template: P, params: Record<string, string | number>): string;
@@ -44,7 +66,33 @@ export interface CellClientOptions {
44
66
  /** Retries for idempotent calls on transient failures (default 2). */
45
67
  maxRetries?: number;
46
68
  sleep?: (ms: number) => Promise<void>;
69
+ /**
70
+ * Wakes a suspended workspace (resume, or join the active resume/open) within `timeoutMs` and resolves once it runs;
71
+ * resolves `false` when there was nothing to wake (the API already reports it running), and the refusal then
72
+ * surfaces. It throws when the wake fails (OperationFailedError) or outlasts `timeoutMs` (OperationTimeoutError).
73
+ * Workspace.cell() supplies `workspace.wake()`. A call refused with `workspace_not_running` wakes the workspace and is
74
+ * retried; a refused call was never executed (contracts §20.4), so the retry cannot duplicate it. `null` surfaces
75
+ * the refusal instead.
76
+ */
77
+ wake?: ((timeoutMs: number, signal?: AbortSignal) => Promise<boolean | void>) | null;
78
+ /**
79
+ * Total time one call spends waiting for lifecycle transitions: `workspace_busy` waits plus wakes (default
80
+ * 120 000 ms). The wake hook gets what is left of it.
81
+ */
82
+ transitionTimeoutMs?: number;
83
+ /**
84
+ * Progress of this client's tool calls: tool token fetches (action `token`, with their timing), and `tool` events for
85
+ * `workspace_busy` waits (phase `busy`), stale or rejected tokens and transient failures retried (type `retry`).
86
+ * Through Workspace.cell() a wake reports here too (action `wake`).
87
+ */
88
+ onProgress?: ProgressListener;
89
+ }
90
+ /** Per-request transition handling (internal to CellClient). */
91
+ interface TransitionOptions {
92
+ /** Wake a suspended workspace for this call (default true; exec output follow reconnects pass false). */
93
+ wake?: boolean;
47
94
  }
95
+ export declare const DEFAULT_TRANSITION_TIMEOUT_MS = 120000;
48
96
  export interface RunResult {
49
97
  sessionId: string;
50
98
  exitCode: number | null;
@@ -94,8 +142,21 @@ export declare class CellClient {
94
142
  readonly workspaceId: string;
95
143
  readonly tokens: ToolTokenManager;
96
144
  constructor(workspaceId: string, tokens: ToolTokenManager, opts?: CellClientOptions);
97
- /** One authorized request; refreshes the token once on stale_epoch / 401. */
98
- request(method: string, path: string, init?: RequestOptions): Promise<Response>;
145
+ /** True after close(): every request (and stream) of this client is aborted. */
146
+ get closed(): boolean;
147
+ /**
148
+ * Aborts every in-flight and future request of this client, including exec output streams and PTY reads (commands
149
+ * keep running in the workspace: nothing is canceled there). Workspace.close() calls it.
150
+ */
151
+ close(): void;
152
+ /**
153
+ * One authorized request. Refreshes the token once on stale_epoch / 401. Lifecycle transitions (contracts §20.4),
154
+ * bounded in total by `transitionTimeoutMs`: a call refused with `workspace_busy` is retried after `Retry-After`; one
155
+ * refused with `workspace_not_running` (or whose token cannot be minted because the workspace is not running) wakes
156
+ * the workspace through `wake`, given the time left, and is retried with a fresh token, at most 3 wakes per call.
157
+ * Refused calls were never executed, so retrying is safe. When the budget is spent the refusal surfaces.
158
+ */
159
+ request(method: string, path: string, init?: RequestOptions & TransitionOptions): Promise<Response>;
99
160
  readonly exec: {
100
161
  /** Starts argv (no shell). Idempotent by session_id: an existing session is returned, never re-run. */
101
162
  start: (req: ExecStartRequest, signal?: AbortSignal) => Promise<ExecSession>;
@@ -182,6 +243,10 @@ export declare class CellClient {
182
243
  overwrite?: boolean;
183
244
  }) => Promise<FileInfo>;
184
245
  };
246
+ /** One page of the workspace's changes against its template (needs the `files` tool). */
247
+ changes(params?: WorkspaceChangesParams): Promise<WorkspaceChangesPage>;
248
+ /** Every change under `pathPrefix`, following next_cursor. */
249
+ changesAll(params?: Omit<WorkspaceChangesParams, 'cursor' | 'summary'>): AsyncGenerator<WorkspaceChange>;
185
250
  readonly git: {
186
251
  clone: (req: GitCloneRequest) => Promise<GitResult>;
187
252
  status: (path: string) => Promise<GitStatus>;