@shardflux/sdk 0.8.0 → 0.10.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/README.md CHANGED
@@ -10,8 +10,8 @@ tool calls into the workspace ([tool-call capture](#tool-call-capture-070)).
10
10
  > **Early access.** Shardflux is in early access. The API is versioned (`/v1`), but this SDK is
11
11
  > below 1.0: a minor release may contain breaking changes (see [Compatibility](#compatibility)).
12
12
 
13
- > **Versions.** This README describes 0.8.0. Anything marked **(0.8.0+)** is not in 0.7.x, **(0.7.0+)** not in 0.6.x
14
- > and **(0.6.0+)** not in 0.5.0; [CHANGELOG.md](./CHANGELOG.md) lists what each version added. Check yours with
13
+ > **Versions.** This README describes 0.10.0. Anything marked **(0.10.0+)** is not in 0.9.0, **(0.9.0+)** not in 0.8.x, **(0.8.0+)** not in 0.7.x,
14
+ > **(0.7.0+)** not in 0.6.x and **(0.6.0+)** not in 0.5.0; [CHANGELOG.md](./CHANGELOG.md) lists what each version added. Check yours with
15
15
  > `npm ls @shardflux/sdk` or the exported `SDK_VERSION`.
16
16
 
17
17
  - ESM only, no runtime dependencies, Node.js 24 or later. Reading a YAML template file uses the optional peer
@@ -67,11 +67,12 @@ const cloud = new Shardflux({
67
67
  baseUrl: process.env.SHARDFLUX_API_URL, // optional: default https://api.shardflux.dev
68
68
  timeoutMs: 30_000, // optional: per-request timeout
69
69
  maxRetries: 2, // optional: retries of safe or idempotent requests
70
+ versionCheck: true, // optional (0.9.0+): see Version check
70
71
  });
71
72
  ```
72
73
 
73
- The SDK reads nothing from the environment by itself. `SHARDFLUX_API_KEY` and `SHARDFLUX_API_URL`
74
- are the conventional names (the `shard` CLI reads them); pass them in as shown.
74
+ The SDK reads nothing from the environment by itself (except the version check's opt-out). `SHARDFLUX_API_KEY` and
75
+ `SHARDFLUX_API_URL` are the conventional names (the `shard` CLI reads them); pass them in as shown.
75
76
 
76
77
  ## Commands and files
77
78
 
@@ -100,6 +101,70 @@ await cell.files.remove('/home/user/data.bin');
100
101
  twice. Aborting its `signal` also cancels the command in the workspace. File writes are atomic
101
102
  and durable (acknowledged after fsync).
102
103
 
104
+ `cwd` is an absolute path (commands start in `/home/user` without one). The API refuses a relative
105
+ `cwd` with 422 `validation_failed` (`err.reason === 'invalid_cwd'`); the message names the absolute
106
+ path it likely means. A command that could not start (a `cwd` that is not a directory, a program
107
+ that is not on `PATH`) rejects with `ExecStartError` **(0.10.0+)**, whose message is the workspace's
108
+ reason; before 0.10.0 `exec.run()` resolved with `exitCode: null` and no output.
109
+
110
+ ```ts
111
+ import { ExecStartError } from '@shardflux/sdk';
112
+
113
+ try {
114
+ await cell.exec.run(['ls'], { cwd: '/home/user/app' });
115
+ } catch (err) {
116
+ if (!(err instanceof ExecStartError)) throw err;
117
+ console.error(err.message); // The command could not start: working directory "/home/user/app" is not a directory
118
+ }
119
+ ```
120
+
121
+ ### Search, patch and revisions (0.9.0+)
122
+
123
+ ```ts
124
+ const hits = await cell.files.search('/home/user/project', 'TODO', { include: ['**/*.py'], contextLines: 1 });
125
+ for (const m of hits.matches) console.log(`${m.path}:${m.line}:${m.column}: ${m.text}`);
126
+
127
+ // A file's revision is the SHA-256 of its content.
128
+ const { revision } = await cell.files.stat('/home/user/project/app.py', { revision: true });
129
+ const patched = await cell.files.patch({
130
+ path: '/home/user/project/app.py',
131
+ edits: [{ oldText: 'DEBUG = True', newText: 'DEBUG = False' }], // must occur exactly once (or replaceAll)
132
+ expectedRevision: revision, // refused if the file changed meanwhile
133
+ });
134
+ patched.revision; // the next expectedRevision
135
+
136
+ const { data, revision: current, servedFrom } = await cell.files.readWithInfo('/home/user/project/app.py');
137
+ ```
138
+
139
+ - `search()` searches a directory (or one file) and returns matching lines in path order (`path`, 1-based `line` and
140
+ byte `column`, `text`), stopping at `maxMatches` (default 200), a 10 s budget or 4 MiB of results (`truncated`,
141
+ `stop_reason`). `include`/`exclude` globs are gitignore-style: `*.py` matches a name at any depth, `src/**/*.ts`
142
+ a path relative to the searched directory, `build/` directories only. Binary files, symbolic links, files above
143
+ `maxFileBytes` and `.git`/`node_modules` (unless `exclude` is given) are skipped.
144
+ - `patch()` requests are limited to 7 MiB (413 `payload_too_large`; write larger files with `write()`) and files to
145
+ 64 MiB.
146
+ - `patch()` applies all edits or none, atomically and durably, and always sends an `Idempotency-Key`. `content`
147
+ replaces the whole file instead; `expectedRevision: 'absent'` requires that the file does not exist yet. A changed
148
+ file is 409 `conflict` with `reason` `revision_mismatch` and `details.current_revision`; an edit that does not match
149
+ exactly once is 422 `edit_not_found` or `edit_ambiguous` with `details.index`.
150
+ - A suspended workspace whose disk is still on a host is read, listed and searched there without waking it; such
151
+ results say `servedFrom: 'disk'` (`served_from` on search results). Everything else wakes it as usual. This also
152
+ works for a handle without a tool token from before the suspend: the API issues tokens for suspended workspaces.
153
+ - While the fleet is being upgraded, a workspace may run on a host that predates search and patches: they are 409
154
+ `conflict` with `reason` `host_feature_unavailable` and `details.feature` (`file_search`, `file_patch`), not
155
+ retryable (read and write the file, or run `grep` with `exec`, instead), and revisions are omitted.
156
+
157
+ ### Wake hint (0.9.0+)
158
+
159
+ An idle running workspace may be parked by its host (frozen or hibernated) and is restored by the next tool call.
160
+ `workspace.hint()` tells the host a tool call is coming so the restore starts earlier: call it when your model starts
161
+ emitting a tool call, before its arguments are complete. It is cheap and returns at once; a suspended workspace is
162
+ resumed in the background (`result.wake`). The agent tools below send it when each call starts.
163
+
164
+ ```ts
165
+ void workspace.hint().catch(() => {}); // fire and forget
166
+ ```
167
+
103
168
  The same client has `exec.start/get/output/signal/cancel`, `pty`, `processes`, `git` and
104
169
  `browser` (screenshot and page content).
105
170
 
@@ -146,6 +211,29 @@ try {
146
211
  }
147
212
  ```
148
213
 
214
+ ### Suspend when idle (0.10.0+)
215
+
216
+ A running workspace is billed while it is awake, and its idle policy waits a while before suspending it. When your
217
+ agent's turn ends, ask for a suspend once the workspace has been idle for a short time instead:
218
+
219
+ ```ts
220
+ const { suspendRequest } = await workspace.suspendWhenIdle({ afterSeconds: 60 }); // 30..3600
221
+ workspace.suspendRequest; // { requested_at, after_seconds, not_before } until it applies or is cancelled
222
+ await workspace.cancelSuspendWhenIdle(); // idempotent
223
+ ```
224
+
225
+ - The idle time counts from the later of the workspace's last work and the request; `not_before` is the earliest
226
+ suspend.
227
+ - A command still running, an attached exec or terminal stream, or a keepalive postpones the suspend until
228
+ `afterSeconds` after it ends.
229
+ - The next tool call on the workspace (the next turn) or a resume cancels the request. Repeating replaces it.
230
+ - It applies under every idle policy, `never` included, and never delays a suspend the policy would do sooner.
231
+ - When a suspend is already in progress, the result's `operation` is that suspend and nothing is recorded.
232
+ - Errors: `ShardfluxApiError` 409 with `reason` `not_running`, `operation_in_progress`, `session_lifetime` or
233
+ `workspace_deleted`, and 422 `validation_failed` for `afterSeconds` outside 30..3600. A file-first workspace is never
234
+ suspended: `NotSupportedForModeError` (409 `not_supported_for_mode`).
235
+ - By id: `cloud.workspaces.suspendWhenIdle(id, { afterSeconds })` and `cloud.workspaces.cancelSuspendWhenIdle(id)`.
236
+
149
237
  **Suspended workspaces wake on use.** A tool call on a suspended workspace resumes it (or joins the resume or open
150
238
  already running), then runs. A call made during a suspend or resume waits for the transition to finish. The call
151
239
  never runs twice: the cell executes nothing it refused.
@@ -153,8 +241,13 @@ never runs twice: the cell executes nothing it refused.
153
241
  - The wait is bounded per call by `transitionTimeoutMs` (default 120 000 ms), shared by the transition waits and at
154
242
  most 3 wakes. A resume or open still pending at the end throws `OperationTimeoutError`, naming the operation, its
155
243
  state and reason. A failed resume or open throws `OperationFailedError` at once.
244
+ - The wake is one request (0.9.0+): the API holds the resume until the workspace runs and answers with the view and a
245
+ tool token for the calling client, so the refused call is retried at once (refused call, resume, the call). Its
246
+ timing is one `request` phase with reason `held`. An API without the held resume answers at once; the SDK then waits
247
+ for the operation, reads the view and fetches a token, as before.
156
248
  - Following an exec's output never wakes a workspace, so an explicit `suspend()` is respected.
157
- - `workspace.wake({ timeoutMs })` does the same on demand.
249
+ - `workspace.wake({ timeoutMs })` does the same on demand (`agentLabel` / `tools` pick the token it brings back).
250
+ `workspace.resume({ wait: true })` is the same single held request: the handle keeps the view and the token.
158
251
  - `workspace.cell({ wake: null })` returns `workspace_not_running` instead.
159
252
 
160
253
  ```ts
@@ -273,6 +366,50 @@ const page = await ws.changes({ pathPrefix: '/home/user', summary: true }); //
273
366
  `legacy_disk_layout`. `changes()` is served by the cell from the running workspace and needs the `files` tool.
274
367
  `cell().changesAll()` follows the pages.
275
368
 
369
+ ## File-first workspaces (0.9.0+)
370
+
371
+ A file-first workspace has no VM between commands. Its state is a versioned file tree under /home/user (tree
372
+ revisions 0, 1, 2, ...). Each command runs as an execution: a fresh VM on the latest revision, whose changed files
373
+ become the next revision. Nothing else survives an execution (processes, memory, files outside /home/user), so install
374
+ dependencies into /home/user (for example a virtualenv) and start servers within the command that uses them. It is
375
+ ready as soon as it is opened and is never suspended.
376
+
377
+ ```ts
378
+ const ws = await cloud.workspaces.open({ key: 'customer-42/repo', template: 'python-node-browser', mode: 'file_first' });
379
+ const cell = ws.cell();
380
+ await cell.files.write('/home/user/app/main.py', 'print("hi")\n', { createParents: true });
381
+
382
+ const r = await ws.executions.run(['bash', '-lc', 'cd app && python3 main.py > out.txt && cat out.txt'], { timeoutMs: 600_000 });
383
+ r.state; // 'succeeded' (any exit code), 'failed' or 'lost' (nothing published; r.errorReason says why)
384
+ r.exitCode; // 0
385
+ r.stdoutText; // 'hi\n' (r.stdout holds the bytes)
386
+ r.changed; // [{ path: '/home/user/app/out.txt', change: 'added', type: 'file' }]
387
+ r.treeRevision; // 2: the revision the execution published (= baseRevision when it changed nothing)
388
+ ws.treeRevision; // 2
389
+
390
+ // Conditional writes: applied only if the tree is still at that revision.
391
+ await cell.files.write('/home/user/app/main.py', 'print("bye")\n', { ifTreeRevision: ws.treeRevision! });
392
+ // ... else TreeRevisionMismatchError (err.currentTreeRevision): read what changed and try again.
393
+ ```
394
+
395
+ - **Executions are idempotent by id.** `executionId` defaults to a fresh `ex-<uuid>`; pass your own to make a call safe
396
+ to repeat across processes. Network failures and retryable 5xx answers (503 `no_execution_host`: no host has room;
397
+ the SDK waits `Retry-After`) are retried with the same id, at most `maxRetries` (5) times, and an answer that takes
398
+ longer than `attemptTimeoutMs` (300 s) is awaited again with the same id. The cell runs a command once per id: a
399
+ repeated call gets the recorded result (`r.replayed`). The SDK never retries with a new id: a `failed` or `lost`
400
+ result is returned, and running it again is your decision.
401
+ - `ws.executions.get(id, { waitMs })` reads an execution: `pending` while it runs, its result when it ended (kept 7
402
+ days). Use it after a call that stopped waiting (an aborted `signal`): an execution cannot be canceled.
403
+ - While an execution runs, other executions and file writes are refused with 409 `workspace_busy`
404
+ (`execution_in_progress`); the SDK waits them out within `transitionTimeoutMs` (120 s).
405
+ - Only paths under /home/user exist (`outside_tree_root` otherwise). Reads, writes, `search()` and `patch()` work as on
406
+ a processful workspace, on the latest revision.
407
+ - Calls a file-first workspace does not have fail with `NotSupportedForModeError` before any request: exec sessions
408
+ (`cell.exec.*`), PTY, processes, git, browser, `changes()`, `suspend`, `resume`, `snapshot`, `fork`, `reset` and
409
+ `saveAsTemplate`. On a processful workspace `executions` and `ifTreeRevision` are refused the same way.
410
+ - Reopening a key with another `mode` is 409 `mode_mismatch`; a deployment without file-first workspaces answers 422
411
+ `mode_not_available`; a legacy template 409 `layout_unsupported`.
412
+
276
413
  ## Templates: file tree, diff and dev mode
277
414
 
278
415
  ```ts
@@ -403,6 +540,18 @@ it.
403
540
  Your agent loop and model calls stay in your application; the workspace is the computer the tools
404
541
  act on.
405
542
 
543
+ The tools are `exec`, `read_file`, `write_file`, `list_files`, `search_files` and `edit_file` (0.9.0+), the process,
544
+ terminal, git and browser tools, filtered by the tools your key grants. `edit_file` replaces exact text; when the
545
+ model passes no `expected_revision` it reads the file's revision first, so a change made in between fails the edit
546
+ instead of being overwritten. Each call first sends `workspace.hint()` without waiting for it (`hint: false` turns
547
+ that off, e.g. when you send the hint yourself as the model starts a tool call), except `read_file`, `list_files` and
548
+ `search_files`: a sleeping workspace answers them from its disk without waking.
549
+
550
+ For a file-first workspace (0.9.0+) the tools are `exec` and the files tools only: `exec` runs each command as an
551
+ execution and adds `execution_id`, `state`, `tree_revision` and `changed` to its result, and the process, terminal,
552
+ git and browser tools are not offered. `workspaceTools(ws, { mode })` builds the definitions without touching the
553
+ workspace; `onExecution(id)` is called with each execution id before it is sent.
554
+
406
555
  ## Tool-call capture (0.7.0+)
407
556
 
408
557
  Your harness's tools (web search, SQL, HTTP APIs, MCP servers) run in your application, so their results reach the
@@ -480,7 +629,7 @@ never injected).
480
629
 
481
630
  **Read-your-writes.** Calls through the same client first wait for capture writes recorded before them (bounded by
482
631
  `settleTimeoutMs`, 30 s; they never fail because of capture): `exec` and files calls through `workspace.cell()`,
483
- `workspaceTools`, `snapshot`, `fork`, `suspend`, `saveAsTemplate` and `close` (on the workspace handle and on
632
+ `workspaceTools`, `snapshot`, `fork`, `suspend`, `suspendWhenIdle` (0.10.0+), `saveAsTemplate` and `close` (on the workspace handle and on
484
633
  `cloud.workspaces.*(id)`). A lifecycle call's timing shows the wait as a `capture_flush` phase. `delete` and `reset`
485
634
  drop pending writes. A write to a suspended workspace wakes it (`wake: null` opts out).
486
635
 
@@ -542,6 +691,78 @@ await workspace.cell().exec.run(['python3', 'agent.py']); // sees $OPEN
542
691
  `accessEvents`, `createOrganization` and `listOrganization`. Organization-wide secrets and access
543
692
  logs belong to organization owners and admins, so a project API key gets 403 for those.
544
693
 
694
+ ## Account (ShardfluxAccount) (0.9.0+)
695
+
696
+ `ShardfluxAccount` does what a person does in the web app, with a user session instead of an API key: sign up, sign
697
+ in (with MFA), organizations, projects, API keys, members, invitations, billing, audit, data export and account
698
+ deletion. Two steps stay human: opening the verification email, and paying in Stripe Checkout.
699
+
700
+ ```ts
701
+ import { Shardflux, ShardfluxAccount } from '@shardflux/sdk';
702
+
703
+ // Before a session exists (static; no token needed). Emailed links can be passed whole.
704
+ await ShardfluxAccount.register({ email, password, displayName: 'Ada' }); // { status: 'accepted' }
705
+ await ShardfluxAccount.verifyEmail('https://app.shardflux.dev/auth/verify-email#token=...');
706
+
707
+ const { account, result } = await ShardfluxAccount.login({ email, password, onSessionToken: (s) => save(s.token) });
708
+ if (result.status === 'mfa_required') await account.auth.completeMfa({ code: '123456' }); // or { recoveryCode }
709
+
710
+ // Later, with the saved token (sfu_<43 characters>; a malformed one throws).
711
+ const again = new ShardfluxAccount({ sessionToken: saved, onSessionToken: (s) => save(s.token) });
712
+ const org = await again.organizations.create({ name: 'Acme' });
713
+ const project = await again.projects.create(org.id, { name: 'Default' });
714
+ const { secret } = await again.apiKeys.create(project.id, { name: 'agent', toolPermissions: ['exec', 'files'] });
715
+ const cloud = new Shardflux({ apiKey: secret }); // the sfk_ key, shown once
716
+ ```
717
+
718
+ - The session token is sent as `Authorization: Bearer sfu_...` on `/v1`. Login completion, `auth.stepUp()`,
719
+ `auth.changePassword()`, `auth.totp.confirm()` and `auth.totp.disable()` rotate it (the old token stops working):
720
+ `account.sessionToken` always holds the current token, every later call uses it, and `onSessionToken({ token,
721
+ expiresAt })` is called (and awaited) with each new one. Save it there. Sessions idle out after 30 days.
722
+ - Sensitive calls (exports, deletions, TOTP changes, …) answer 403 `step_up_required` without a recent password
723
+ check: `await account.auth.stepUp({ password, code })`, then retry. A session still waiting for its second factor
724
+ gets 403 `mfa_required`; an unverified email 403 `email_unverified`.
725
+ - Namespaces: `auth` (session, MFA, logout, sessions, step-up, password and email change, `totp`), `organizations`
726
+ (`list`, `listAll`, `create`, `get`, `entitlements`, `deletion`, `delete`, `exports`, `workspaces`), `projects`,
727
+ `apiKeys` (`create` sends an Idempotency-Key, so a retry never makes a second key; `toolPermissions` defaults to
728
+ `[]`), `members`, `invitations` (`accept(linkOrToken)`), `billing`, `user` (account `deletion`, `scheduleDeletion`,
729
+ `cancelDeletion`, `exports`), `templates` (plus `publishVersion` and `archiveVersion`), `audit` (plus `export(orgId,
730
+ { format: 'csv' | 'ndjson', ...filters })`, the text), and `usage`, `secrets`, `egress`, `volumes` with explicit
731
+ organization and project ids; `me()` and `request()`. List methods return the API's page `{ data, next_cursor }`;
732
+ `listAll` iterates every page.
733
+ - `parseEmailToken(input)` returns the `token` of a link's `#token=` fragment or `?token=` query (else the trimmed
734
+ input), and throws for a link without one.
735
+
736
+ Upgrading a plan: a person pays at the Checkout `url`; the code waits for the subscription.
737
+
738
+ ```ts
739
+ const checkout = await account.billing.checkout(org.id, { planKey: 'pro' }); // 409 subscription_exists: use billing.portal()
740
+ console.log(`Pay here: ${checkout.url}`);
741
+ const done = await account.billing.waitForCheckout(org.id, checkout.id, { timeoutMs: 15 * 60_000 });
742
+ if (!done.subscription_active) console.log(`checkout ${done.status}`); // expired, canceled or failed
743
+ // After timeoutMs: CheckoutTimeoutError (err.checkout is the last status); on abort: the signal's reason.
744
+ ```
745
+
746
+ Opt-in overage and its spend cap (owners and billing members): read the policy, then change it. Every field is
747
+ optional (give at least one); `ifMatch` (the `version` you read) makes a concurrent change a 409 `version_mismatch`
748
+ instead of overwriting it.
749
+
750
+ ```ts
751
+ const policy = await account.billing.spendPolicy(org.id);
752
+ // overage_state: unavailable | off | on | paused; the cap range: spend_cap_min_minor..spend_cap_max_minor (the plan price)
753
+ if (policy.overage_available) {
754
+ await account.billing.setSpendPolicy(org.id, { overageEnabled: true, spendCapMinor: 900, ifMatch: policy.version }); // $9.00
755
+ }
756
+ await account.billing.setSpendPolicy(org.id, { overageEnabled: false }); // always allowed
757
+ await account.billing.setSpendPolicy(org.id, { alertThresholdsPercent: [50, 80, 100] });
758
+ ```
759
+
760
+ A refused change is a `ShardfluxApiError` 422 `validation_failed` with `reason` `overage_unavailable`,
761
+ `spend_cap_required`, `spend_cap_below_minimum` (`details.min_minor`), `spend_cap_above_plan_price`
762
+ (`details.max_minor`) or `spend_cap_below_charges` (`details.charges_minor`: the cap cannot go below what overage
763
+ already charged this period). Every owner and billing member gets an email when overage is turned on or off or the cap
764
+ changes.
765
+
545
766
  ## Errors
546
767
 
547
768
  - `ShardfluxApiError`: the API or the workspace refused the request. Fields: `status`, `code`,
@@ -554,17 +775,127 @@ await workspace.cell().exec.run(['python3', 'agent.py']); // sees $OPEN
554
775
  - `OperationTimeoutError`: waiting gave up; the operation continues (`operationId`, `lastState`, `lastReason`,
555
776
  `deadlineAt` **(0.6.2+)** while it waits for capacity, `timing`).
556
777
  - `ShardfluxProtocolError`: a response was not the documented shape.
778
+ - `NotSupportedForModeError` **(0.9.0+)**, a `ShardfluxApiError` (409 `conflict`, `reason` `not_supported_for_mode`):
779
+ the call does not exist for the workspace's `mode`; `local` is true when the SDK refused it without a request.
780
+ - `TreeRevisionMismatchError` **(0.9.0+)**, a `ShardfluxApiError` (409 `conflict`, `reason` `tree_revision_mismatch`):
781
+ an `ifTreeRevision` call found the tree at `currentTreeRevision`; nothing changed.
782
+ - `CheckoutTimeoutError` **(0.9.0+)**: `billing.waitForCheckout()` gave up; the checkout stays open (`checkout`,
783
+ `waitedMs`).
557
784
 
558
785
  Treat unknown error codes and reasons as generic errors: show `message`, and use `retryable`.
559
786
 
787
+ A 402 `allowance_exhausted` (opens, resumes and forks refused while a CPU-hours or RAM GiB-hours allowance is used up)
788
+ carries `reason` **(0.9.0+)**:
789
+
790
+ | `reason` | Meaning | What to do |
791
+ | --- | --- | --- |
792
+ | `allowance_used` | The allowance is used up and overage is off or not on the plan. | Upgrade, or have an owner or billing member turn on overage under Usage & billing; or wait for `details.resets_at`. |
793
+ | `overage_paused` | Overage is on but paused while a plan payment is past due. | An owner or billing member updates the payment method. |
794
+ | `spend_cap_reached` | Overage charges reached the spend cap for this billing period. | Raise the cap (up to the plan price) or upgrade under Usage & billing; or wait for `details.resets_at`. |
795
+
796
+ `details.spend_cap` is `{ cap_minor, effective_cap_minor, charges_minor, currency }` (minor units, `null` when the plan
797
+ has no overage). An older API sends no `reason`. Do not retry these in a loop.
798
+
799
+ Retryable 429/502/503/504 refusals (for example 503 `host_capacity`, when the workspace's host has no room to restore
800
+ it right now, or `wake_failed`) are retried after `Retry-After` for reads, searches and calls that carry an
801
+ Idempotency-Key (writes and patches); other calls surface them with `retryable: true` and `retryAfterSeconds`.
802
+ A read of a sleeping workspace that its disk cannot answer (409 `workspace_not_running` with `reason`
803
+ `offline_unavailable` or `offline_budget`) wakes the workspace and is retried like any `workspace_not_running`; 503
804
+ `offline_changed` (the disk changed during the read) is retried and served by the running workspace. 409 `conflict`
805
+ `host_feature_unavailable` (the workspace's host predates the call, `details.feature`) is neither retried nor
806
+ woken: it lasts until the workspace runs on an upgraded host.
807
+
808
+ ## Usage and overage
809
+
810
+ `cloud.usage` reads the organization's usage (API keys see organization totals and their own project's workspaces):
811
+ `summary(orgId)`, `series(orgId, params)`, `workspace(workspaceId, params)`, `estimate(orgId)`, `grants(orgId)`,
812
+ `spend(orgId)` and `spendPolicy(orgId)`.
813
+
814
+ ```ts
815
+ const s = await cloud.usage.summary(orgId);
816
+ if (s.allowance_exhausted) console.log('starts are refused:', s.exhausted_reason); // allowance_used | overage_paused | spend_cap_reached
817
+ const cap = s.spend_cap; // opt-in overage this period (0.10.0+)
818
+ const usd = (minor: number) => `$${(minor / 100).toFixed(2)}`; // amounts are minor units of cap.currency
819
+ if (cap.state === 'accruing' || cap.state === 'warning') {
820
+ console.log(`overage ${usd(cap.charges_minor)} of ${usd(cap.effective_cap_minor)}; cap reached ${cap.projected_reached_at ?? 'not this period'}`);
821
+ }
822
+ ```
823
+
824
+ Opt-in overage **(0.10.0+)**: while an owner or billing member has turned it on, workspaces keep opening and running
825
+ past the CPU-hours and RAM GiB-hours allowances (those allowances show `cap_state: 'overage'`), and the usage past them
826
+ is charged on the next invoice until the charges reach the spend cap.
827
+
828
+ - `summary()`, `spend()` and `estimate()` carry `spend_cap` (type `SpendCap`): `state` (`unavailable`, `off`,
829
+ `paused`, `within_allowance`, `accruing`, `warning`, `reached`), `cap_minor`, `effective_cap_minor`,
830
+ `charges_minor`, `remaining_minor`, `percent_of_cap`, `currency`, `resets_at`, `lines` (per allowance: `units_over`,
831
+ `billed_units`, `rate_minor`, `amount_minor`) and `projected_reached_at`. `summary()` and `spend()` also carry
832
+ `exhausted_reason`.
833
+ - `spendPolicy()` returns the settings: `overage_available`, `overage_enabled`, `overage_state` (`unavailable`, `off`,
834
+ `on`, `paused`), `spend_cap_minor`, `spend_cap_min_minor`, `spend_cap_max_minor`, `rates` and `currency`.
835
+ - An API key only reads them. Owners and billing members turn overage on or off and change the cap in the console,
836
+ or with a user session: `ShardfluxAccount.billing.setSpendPolicy()` (see below).
837
+
838
+ ## Feedback (0.9.0+)
839
+
840
+ `cloud.sendFeedback()` sends a message straight to the Shardflux founder, who reads every one. If you or your coding
841
+ agent hit something while building with Shardflux, send it the moment it happens: a call that failed unexpectedly, an
842
+ error or doc that was confusing, something missing or slow, a workaround you needed. Short and specific beats polished;
843
+ the request id and error code let the founder find the logs.
844
+
845
+ ```ts
846
+ try {
847
+ await cloud.workspaces.open({ key: 'acme/demo', template: 'python-node-browser' });
848
+ } catch (err) {
849
+ if (err instanceof ShardfluxApiError) {
850
+ await cloud.sendFeedback({
851
+ message: 'open failed with capacity_unavailable twice in 10 minutes; expected a start within a minute',
852
+ category: 'bug',
853
+ context: { requestId: err.requestId, errorCode: err.code, workspace: 'acme/demo', agent: 'claude-code' },
854
+ });
855
+ }
856
+ throw err;
857
+ }
858
+ ```
859
+
860
+ - `message`: 1-8000 characters. `category`: `bug` (something failed or behaved wrongly), `confusing` (an error, doc,
861
+ name or output was unclear), `missing` (a capability, option or template you needed), `idea`, `praise` or `other`
862
+ (the default).
863
+ - `context` (all optional): `agent` (who is reporting, e.g. `claude-code`), `workspace`, `requestId`, `errorCode`,
864
+ `command` (the call that led to it), and `client`, which defaults to `shardflux-sdk-ts/<version>`.
865
+ - Returns `{ id, receivedAt, duplicate }`. The same message from the same key within 24 hours returns the original
866
+ with `duplicate: true` and sends no second email.
867
+ - Any API key may send feedback. Signed in with a CLI session instead, `account.sendFeedback({ message, category?,
868
+ context?, organizationId? })` sends it as the user (`ShardfluxAccount`; `organizationId`: one of yours). It is rate
869
+ limited per key or user: a `ShardfluxApiError` with `code: 'rate_limited'` and `retryAfterSeconds`. An empty or too-long message is `validation_failed`. The SDK never retries the call.
870
+ - Anything shaped like an API key is redacted before the message is stored or emailed; still, leave secrets out.
871
+
560
872
  ## More of the API
561
873
 
562
874
  The `Shardflux` object also has `templates` (including custom template builds and the template editor), `volumes`
563
875
  (shared persistent storage attached to workspaces), `secrets` (see above), `egress` (outbound allowlists),
564
- `usage`, `billing`, `me()`, `entitlements(orgId)` and `request(method, path)` for any `/v1`
876
+ `usage`, `billing`, `me()`, `entitlements(orgId)`, `sendFeedback()` and `request(method, path)` for any `/v1`
565
877
  route. The package exports the OpenAPI-generated types as well (`paths`, `components`,
566
878
  `WorkspaceView`, `Operation` and more).
567
879
 
880
+ ## Version check (0.9.0+)
881
+
882
+ After the first successful API response of the process, the SDK asks `GET /v1/client-versions` in the background
883
+ (once per process, 3 s timeout, every error ignored; it never delays or fails a call). When this version is outdated
884
+ or no longer supported, it emits one warning:
885
+
886
+ ```
887
+ (node:1234) [SHARDFLUX_UPDATE_AVAILABLE] ShardfluxUpdateWarning: @shardflux/sdk 0.9.0 is outdated: 0.10.0 is available. Update: npm install @shardflux/sdk@latest
888
+ ```
889
+
890
+ - Turn it off with `versionCheck: false` (on `Shardflux` or `ShardfluxAccount`), `SHARDFLUX_NO_UPDATE_CHECK=1` (also
891
+ `true`, `yes`, `on`) or `NO_UPDATE_NOTIFIER=1`. Handle it with `process.on('warning', (w) => ...)`
892
+ (`w.name === 'ShardfluxUpdateWarning'`).
893
+ - A tool built on the SDK checks its own package instead: `versionCheck: { package: '@acme/tool', version: '1.2.3' }`.
894
+ - On demand: `await checkClientVersion()` returns `{ status, package, ecosystem, current, latest, minimumSupported,
895
+ upgradeCommand, releaseNotesUrl, message? }` with `status` `current`, `outdated`, `unsupported` or `unknown` (the
896
+ request failed, or the package is not listed yet). `compareVersions(a, b)` compares two `major.minor.patch` versions
897
+ (a pre-release sorts first; `null` when one does not parse).
898
+
568
899
  ## Compatibility
569
900
 
570
901
  - The SDK follows the API's `/v1` contract. New fields, enum values and error codes can appear in