@shardflux/sdk 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -3,6 +3,116 @@
3
3
  Every API the README shows is available from the version named here. Breaking changes ship in minor releases and are
4
4
  marked **Breaking**.
5
5
 
6
+ ## 0.13.0 (not yet published)
7
+
8
+ ### Elastic memory
9
+
10
+ Additive. A workspace may be promised more memory than it holds while idle, and the host grows it when a command needs
11
+ it (opt-in per workspace).
12
+
13
+ - `Caps.allocation_mode` (`'fixed' | 'elastic'`, type `AllocationMode`) and `Caps.memory_mib_held` on `open()`,
14
+ `workspaces.fork()` and `workspace.fork()`. Given caps replace the stored ones (caps without `allocation_mode` make
15
+ the workspace fixed); omitted caps keep the stored layout.
16
+ - `workspace.memory` (`WorkspaceMemory`: `{allocation_mode, promised_mib, held_mib, plugged_mib}`, null on an older
17
+ API) and `workspace.allocationMode` (`'fixed'` on an older API). `WorkspaceView` carries `caps.allocation_mode`,
18
+ `caps.memory_mib_held` and `memory` (regenerated contract types).
19
+ - `RunResult.memoryGrow` (`MemoryGrow | null`): the grow the exec's start waited for (`outcome` delivered, partial,
20
+ missed or failed; `from_mib`, `want_mib`, `got_mib`, `deliver_ms`), from the start's session. `ExecSession`
21
+ carries `memory_grow`. The `exec` agent tool adds `memory_grow` to its result when a grow ran.
22
+ - `KnownErrorReason` adds `allocation_mode_not_available`, `requires_elastic` and `exceeds_memory_mib` (422
23
+ `validation_failed`; elastic with file-first is the existing `not_supported_for_mode`).
24
+
25
+ ### Burst execution
26
+
27
+ Additive. One heavy, run-to-completion command can run on a larger, short-lived burst VM on the workspace's host, and
28
+ its file changes are applied back.
29
+
30
+ - `RunOptions.burst` (`'never' | 'always'`), `burstVcpus` and `burstMemoryMib` on `exec.run()`; `ExecStartRequest`
31
+ carries `burst`, `burst_vcpus` and `burst_memory_mib` (regenerated contract types).
32
+ - `RunResult.burst` (`BurstSummary | null`): host, size, method, whether the changes were applied, the change counts,
33
+ `leftover_killed`, `overhead_ms`, `excluded_paths`, `timings`, `replayed`. `ExecSession.burst` carries it; types
34
+ `BurstSummary` and `BurstError` are exported.
35
+ - `exec.run()` rejects with `ShardfluxApiError` 409 `burst_unavailable` or `burst_apply_failed` also when the burst
36
+ fails after its start (the output stream's final error event, or `burst.error` on the ended session), without
37
+ reconnecting; it does not try to cancel a burst when its `signal` aborts (a burst cannot be canceled).
38
+ - `ErrorCode` adds `burst_unavailable` and `burst_apply_failed`; `KnownErrorReason` adds `burst_mode_not_supported`,
39
+ `burst_not_supported`, `burst_size_exceeds_plan`, `not_available`, `shared_volumes`, `fence_not_drained`,
40
+ `apply_pending`, `park_failed`, `workspace_resumed`, `interrupted`, `burst_lost`, `disk_full`, `apply_failed`,
41
+ `reverted` and `revert_failed`.
42
+ - `workspaceTools(ws, { burst: true })` (opt-in, default off) gives the processful `exec` tool the inputs `burst`,
43
+ `burst_vcpus` and `burst_memory_mib`, and its result adds `burst`. The default tool schemas are unchanged.
44
+
45
+ ### Background commands in the agent tools
46
+
47
+ Additive. An agent can start a long command, keep working with the other tools, read its progress and stop it.
48
+
49
+ - The processful `exec` tool takes `background: true`: it starts the command and returns `{session_id, state}` at
50
+ once (a failed start adds `error: {code, message, reason}`, as the foreground `exec` gives it). `timeout_ms` goes up
51
+ to 86400000 (a day); with `background` it is sent only when given. The file-first `exec` is unchanged.
52
+ - New tool `exec_read` (permission `exec`): the session's `state`, `exit_code`, `term_signal`, `timed_out`,
53
+ `canceled` and output. Without offsets it returns the last `maxOutputBytes` of each stream; `stdout_offset` /
54
+ `stderr_offset` read from there, and the result's `next_stdout_offset` / `next_stderr_offset` continue where it
55
+ stopped (`truncated` when more output follows). Text is cut at character boundaries only. `wait_ms` (up to 60000)
56
+ waits for the command to exit and returns as soon as it does. `burst` and `error` when the session has them.
57
+ - New tool `exec_cancel` (permission `exec`): SIGTERM, then SIGKILL after `grace_ms` (1-60000, default 5000);
58
+ returns `{session_id, state, exit_code, canceled}`.
59
+ - With `workspaceTools(ws, { burst: true })`, `background: true` together with `burst: 'always'` is refused with
60
+ `ToolArgumentError` before any request (a burst holds the workspace until it applies its changes); `burst: 'never'`
61
+ with `background` is an ordinary background start.
62
+ - `exec_read` and `exec_cancel` follow `exec` in `workspaceTools()` on processful workspaces; a file-first workspace
63
+ does not offer them. Both send the wake hint like the other tools.
64
+
65
+ ### Held fork
66
+
67
+ Additive. A waited fork returns the running copy and its tool token in one request.
68
+
69
+ - `workspaces.fork()` and `workspace.fork()` with `wait`: when the API holds the fork (`Prefer: wait`, answered 200
70
+ with `Preference-Applied`), the copy's handle adopts the returned running view and final-epoch tool token, with no
71
+ operation poll, view refresh or token request. The request's time counts against `wait.timeoutMs`.
72
+ - `ForkOptions` and `WaitedForkOptions` (exported) add `agentLabel` and `tools`, which choose that token. They are sent
73
+ only on a server-held fork and need an API with held fork. `wait: { serverWait: false }` keeps polling.
74
+ - An API that does not apply `Prefer` answers 202 as before; the call then polls the operation and refreshes the copy.
75
+ - The fork's progress `request` phase has reason `held` when it asks the server to wait.
76
+
77
+ ### Exec start and output in one request
78
+
79
+ - `exec.run()` starts the command and follows its output in one request: the start asks for NDJSON
80
+ (`Accept: application/x-ndjson`) and a cell that supports it answers with the output stream. A dropped stream
81
+ reconnects by session and byte offsets, never starting the command again. A cell that answers the start with the
82
+ session JSON gets the output request after it, as before; a start that failed still rejects with `ExecStartError`
83
+ (or the burst's error).
84
+ - `RunResult.memoryGrow` and `burst` are unchanged: the combined stream's exit event carries the start's
85
+ `memory_grow`.
86
+
87
+ ### Immutable paths
88
+
89
+ A template may declare directories as immutable: every workspace of the template mounts them read-only from the
90
+ template's newest published version, picked up at its next cold boot or resume.
91
+
92
+ - Recipe v2 `immutable` (`TemplateRecipeV2.immutable`, regenerated): the list in `template.yaml`, sent as written by
93
+ `buildFromFile()`, `buildFromRecipe()` and `builds.create()`. The stored recipe v2 of a build
94
+ (`TemplateBuildRecipeV2`) and an exported recipe (`versions.recipe()`) carry it.
95
+ - `TemplateVersion.immutable` (`TemplateVersionImmutable`: `{paths, bytes}`, or null when the version declares none),
96
+ on every version of `templates.get()` and on `open_version`.
97
+ - `TemplateBuild.immutable_paths`: the list the produced version declares (the recipe's, else the open version's).
98
+ `TemplateBuild.failure.details` carries code-specific fields: `immutable_path_missing` names `details.path`.
99
+ - `workspace.immutableVersion` (`template.immutable_version` of the view): the template version whose immutable paths
100
+ the workspace has mounted, which can be newer than `template.version`; null when it mounts none.
101
+ - `KnownErrorReason` adds `immutable_path_removed` (422; `details.removed`, `details.open_version`),
102
+ `immutable_paths_unsupported_base` (422; `details.base`, `details.required_feature`, `details.paths`) and
103
+ `read_only_path` (409 `conflict` from the files API for a write under an immutable path).
104
+ - **Breaking:** the update policy left the API. Removed: the `UpdatePolicy` type, `TemplateSummary.update_policy`, the
105
+ regenerated `update_policy` of the workspace view, of `TemplateDefaults` and of the `defaults` inputs, and the reason
106
+ `update_policy_not_available`. It was always `pinned`; code that read it drops the read.
107
+
108
+ ### Exec cancel grace
109
+
110
+ - `exec.cancel(id, graceMs?)` takes `graceMs` 0 to 60000 (0 or omitted: 5000), as the cell API now enforces (422
111
+ `validation_failed` outside it), and waits for the answer up to the grace plus 15 s when that is longer than the
112
+ client's `timeoutMs`: a command that ignores SIGTERM is answered after the grace and the SIGKILL, not a timeout.
113
+ - The cancel that `exec.run` sends when its `signal` aborts passes `killGraceMs` capped at 60000, so a larger
114
+ `killGraceMs` still cancels the command.
115
+
6
116
  ## 0.12.0 (release candidate)
7
117
 
8
118
  Completed exec output streams are drained before releasing their HTTP connections, with a bounded cleanup if a peer does not close.
package/README.md CHANGED
@@ -108,8 +108,9 @@ const listing = await cell.files.list('/home/user');
108
108
  await cell.files.remove('/home/user/data.bin');
109
109
  ```
110
110
 
111
- `exec.run()` resumes from byte offsets if the output stream drops; it never starts the command
112
- twice. Aborting its `signal` also cancels the command in the workspace. File writes are atomic
111
+ `exec.run()` starts the command and streams its output in one request **(0.13.0+)**, resumes from
112
+ byte offsets if the output stream drops, and never starts the command twice. Aborting its `signal`
113
+ also cancels the command in the workspace. File writes are atomic
113
114
  and durable (acknowledged after fsync).
114
115
 
115
116
  `cwd` is an absolute path (commands start in `/home/user` without one). The API refuses a relative
@@ -206,6 +207,12 @@ means depends on `wait`:
206
207
  wait again with `cloud.workspaces.waitForOperation(err.operationId)`. Without `wait`, the returned operation is the
207
208
  handle for the work in progress: pass its `id` to `waitForOperation()` when you need it finished.
208
209
 
210
+ **A waited fork is one request (0.13.0+).** `fork(target, { wait: true })` asks the API to hold the fork until the
211
+ copy runs, and the answer carries the running copy and a tool token for it: the copy's handle is ready with no
212
+ operation poll, view refresh or token request. `agentLabel` and `tools` pick the token it brings back. Its timing is one
213
+ `request` phase with reason `held`; `wait: { serverWait: false }` polls instead. An API without the held fork answers
214
+ at once, and the SDK then waits for the operation and refreshes the copy, as before.
215
+
209
216
  **Durable storage (0.12.0+).** A suspend returns as soon as the workspace is sealed on its host, typically in a few
210
217
  hundred ms, and its RAM and CPU are released at that moment. `result.durable` turns `true` when the copy lands in
211
218
  durable storage, typically within a second; until then it is `false` and `result.durability` shows the copy's
@@ -334,6 +341,46 @@ sessions, template drafts and test instances. `findByKey()` searches every lifet
334
341
  workspace, and a tombstone only when no live workspace has the key. Use it rather than `listAll({ keyPrefix })` for
335
342
  lookups by key.
336
343
 
344
+ ### Elastic memory (0.13.0+)
345
+
346
+ Promise a workspace a lot of memory while it holds only what it uses. An elastic workspace idles at its held floor
347
+ (`memory_mib_held`, default 1024) and grows before each command starts, so compilers, test runners and V8 size
348
+ themselves from the memory they will actually get. After 30 s without work it shrinks back. The layout takes effect at
349
+ the workspace's next start.
350
+
351
+ ```ts
352
+ const ws = await cloud.workspaces.open({
353
+ key: 'customer-42/repo-7',
354
+ template: 'node',
355
+ caps: { memory_mib: 8192, allocation_mode: 'elastic' },
356
+ });
357
+ ws.memory; // { allocation_mode: 'elastic', promised_mib: 8192, held_mib: 1024, plugged_mib: 3072 }
358
+
359
+ const r = await ws.cell().exec.run(['npm', 'test']);
360
+ r.memoryGrow; // { outcome: 'delivered', from_mib: 1024, want_mib: 4096, got_mib: 4096, deliver_ms: 175 }
361
+ ```
362
+
363
+ Given `caps` replace the stored ones, so send `allocation_mode` with every `caps` you pass; an open without `caps`
364
+ keeps the stored layout. The exec agent tool adds `memory_grow` to its result when the command's start grew the VM.
365
+
366
+ ### Burst execution (0.13.0+)
367
+
368
+ Run one heavy command, such as a cold build or a full test suite, on a larger VM without resizing the workspace. With
369
+ `burst: 'always'` the command runs on a short-lived burst VM over a copy of the workspace, output streams as usual,
370
+ and its file changes are applied back byte for byte when it exits.
371
+
372
+ ```ts
373
+ const r = await ws.cell().exec.run(['go', 'build', './...'], { burst: 'always', burstVcpus: 16 });
374
+ r.exitCode; // 0
375
+ r.burst; // { host: 'local', vcpus: 16, memory_mib: 8192, method: 'layer', applied: true,
376
+ // written_files: 3, written_dirs: 1, removed: 1, written_bytes: 40960, overhead_ms: 641, ... }
377
+ ```
378
+
379
+ A burst carries back the command's file changes; processes it started and tmpfs content stay in the burst VM, and the
380
+ workspace's own processes resume where they were. A burst is not signalled or canceled. If applying the changes
381
+ fails (`burst_apply_failed`), retry `exec.run` with the same `sessionId` to finish the apply. The agent tools offer
382
+ bursts when asked: `workspaceTools(ws, { burst: true })`.
383
+
337
384
  ### Timing and progress (0.6.0+)
338
385
 
339
386
  Every open, wake and waited lifecycle call is traced. `workspace.lastTiming` (and `err.timing` when the call fails)
@@ -569,6 +616,27 @@ workspace keeps running so you can inspect it, and `workspace.startup` names the
569
616
  The next open runs the failed step again. Versions report their `settings`, platform templates their `category`
570
617
  (`os` or `stack`), builds the `denied_hosts` their build network refused (add them to `build.network.extra_hosts`).
571
618
 
619
+ ### Immutable paths (0.13.0+)
620
+
621
+ Directories a template declares `immutable` follow the template: every workspace of it mounts them read-only from the
622
+ template's newest published version, so the tools, models or data you ship there reach existing workspaces with your
623
+ next version, at their next cold boot or resume. Everything else in a workspace stays its own.
624
+
625
+ ```yaml
626
+ # template.yaml
627
+ immutable: [/opt/acme] # read-only in every workspace; follows the newest version
628
+ ```
629
+
630
+ ```ts
631
+ const t = await cloud.templates.get('acme-dev');
632
+ console.log(t.open_version?.immutable); // { paths: ['/opt/acme'], bytes: 100663296 }
633
+ const ws = await cloud.workspaces.open({ key: 'customer-42/main', template: 'acme-dev' });
634
+ console.log(ws.template.version, ws.immutableVersion); // created from v1; /opt/acme shows v2
635
+ ```
636
+
637
+ A build without `immutable` keeps the list of the template's open version, and each new version keeps every path of
638
+ it: add paths, never remove them. A build reports the list its version declares in `immutable_paths`.
639
+
572
640
  ## Agent tools
573
641
 
574
642
  `workspaceTools(workspace)` returns tools with a name, a description, a JSON Schema for the
@@ -603,6 +671,20 @@ instead of being overwritten. Each call first sends `workspace.hint()` without w
603
671
  that off, e.g. when you send the hint yourself as the model starts a tool call), except `read_file`, `list_files` and
604
672
  `search_files`: a sleeping workspace answers them from its disk without waking.
605
673
 
674
+ **Background commands (0.13.0+).** On a processful workspace `exec` takes `background: true` for anything that runs
675
+ longer than a few minutes (a build, a test suite, a training run, a server): it returns `{ session_id, state }` at
676
+ once and the model keeps using the other tools. `exec_read` reads the command's state, exit code and output (the end
677
+ of each stream, or from the `next_stdout_offset` / `next_stderr_offset` of the previous read; `wait_ms` waits for the
678
+ exit and returns as soon as it happens), and `exec_cancel` stops it. `timeout_ms` (up to a day) sets the longest the
679
+ command may run; the workspace stays awake while it does. A burst (`burst: 'always'`) runs in the foreground.
680
+
681
+ ```ts
682
+ const tools = workspaceTools(workspace);
683
+ const { session_id } = (await executeToolCall(tools, { name: 'exec', input: { command: 'make -j8 test', background: true, timeout_ms: 7_200_000 } })) as { session_id: string };
684
+ // ... other tool calls ...
685
+ const progress = await executeToolCall(tools, { name: 'exec_read', input: { session_id, wait_ms: 30_000 } });
686
+ ```
687
+
606
688
  For a file-first workspace (0.9.0+) the tools are `exec` and the files tools only: `exec` runs each command as an
607
689
  execution and adds `execution_id`, `state`, `tree_revision` and `changed` to its result, and the process, terminal,
608
690
  git and browser tools are not offered. `workspaceTools(ws, { mode })` builds the definitions without touching the
@@ -859,6 +941,14 @@ A read of a sleeping workspace that its disk cannot answer (409 `workspace_not_r
859
941
  `host_feature_unavailable` (search or patches are not available for the workspace, `details.feature`) is neither
860
942
  retried nor woken: run `grep` with `exec`, or read then write the file, instead.
861
943
 
944
+ Immutable paths **(0.13.0+)**: a files write under an immutable path is 409 `conflict` with `reason` `read_only_path`
945
+ (not retried; write elsewhere, or change the template). A build is refused with 422 `validation_failed` and `reason`
946
+ `immutable_path_removed` when its recipe drops a path of the template's open version (`details.removed`; keep them),
947
+ `immutable_paths_unsupported_base` when its base cannot carry immutable paths (`details.base`; build on a newer version
948
+ of that base), or `invalid_path` (`details.field` `recipe.immutable[<i>]`). A build that fails on them has
949
+ `failure.code` `immutable_path_missing` (`failure.details.path` is not a directory in the built filesystem) or
950
+ `immutable_image_too_large`.
951
+
862
952
  ## Usage and overage
863
953
 
864
954
  `cloud.usage` reads the organization's usage (API keys see organization totals and their own project's workspaces):
@@ -990,7 +1080,7 @@ let ack = await cell.exec.input(session.session_id, 'hello\n', { offset: 0 });
990
1080
  ack = await cell.exec.input(session.session_id, '', { offset: ack.offset, close: true });
991
1081
  ```
992
1082
 
993
- Input frames are at most 64 KiB. The acknowledged offset counts bytes accepted into the pipe, not bytes consumed by the program. A blocked writer may receive a partial acknowledgement; continue from the returned offset. Retrying the identical last frame is safe. `close` sends EOF only once the entire frame is accepted. Do not combine `stdin_open` with the existing one-shot `stdin` option. The transport writes no separate input log or payload file; program output and full-state snapshots retain their normal persistence. Full-state suspend preserves the pipe. Older hosts/guests explicitly refuse this feature until upgraded (`exec_stdin` / `exec_stdin.v1`). Running sessions count as work within the documented idle-command window; long-running services should declare keepalives or choose `never`.
1083
+ Input frames are at most 64 KiB. The acknowledged offset counts bytes accepted into the pipe, not bytes consumed by the program. A blocked writer may receive a partial acknowledgement; continue from the returned offset. Retrying the identical last frame is safe. `close` sends EOF only once the entire frame is accepted. Do not combine `stdin_open` with the existing one-shot `stdin` option. The transport writes no separate input log or payload file; program output and full-state snapshots retain their normal persistence. Full-state suspend preserves the pipe. Where a workspace does not offer it, the start is refused with 409 `conflict`, `reason` `host_feature_unavailable` and `details.feature: 'exec_stdin'`, not retryable. Running sessions count as work within the documented idle-command window; long-running services should declare keepalives or choose `never`.
994
1084
 
995
1085
  ```ts
996
1086
  import { isWorkspaceGone, ShardfluxProtocolError } from '@shardflux/sdk';
package/dist/cell.d.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  * have before sending them (NotSupportedForModeError).
23
23
  */
24
24
  import type { components, paths } from './generated/cell-api.js';
25
+ import { ShardfluxApiError } from './errors.js';
25
26
  import type { WorkspaceMode } from './errors.js';
26
27
  import { ExecutionResult } from './executions.js';
27
28
  import type { ExecutionGetOptions, ExecutionRunOptions } from './executions.js';
@@ -31,6 +32,24 @@ import type { ToolTokenManager } from './tokens.js';
31
32
  type S = components['schemas'];
32
33
  export type ExecStartRequest = S['ExecStartRequest'];
33
34
  export type ExecSession = S['ExecSession'];
35
+ /**
36
+ * Elastic memory (0.13.0): the host grew the workspace's memory before an exec started, and the exec
37
+ * waited for it. `outcome` delivered | partial | missed | failed; `from_mib`, `want_mib`, `got_mib` are guest memory
38
+ * before, aimed for and after; `deliver_ms` the time to deliver. On the session the start returned; absent when no
39
+ * grow ran (fixed workspaces, or already at the exec size).
40
+ */
41
+ export type MemoryGrow = S['MemoryGrow'];
42
+ /**
43
+ * Burst execution (0.13.0; needs an organization entitlement): the session of
44
+ * an exec started with `burst: 'always'` ran on a larger, short-lived burst VM on the workspace's host, and its file
45
+ * changes were applied back (`applied`). `host`, `vcpus`, `memory_mib`, `method`, the change counts
46
+ * (`written_files`, `written_dirs`, `removed`, `written_bytes`), `leftover_killed` (processes the command left behind:
47
+ * they do not survive a burst), `overhead_ms`, `excluded_paths`, `timings`, `replayed`; `error` when the burst failed
48
+ * after the start answered.
49
+ */
50
+ export type BurstSummary = S['BurstSummary'];
51
+ /** The failure recorded on a burst session (`burst.error`): code `burst_unavailable` or `burst_apply_failed`. */
52
+ export type BurstError = S['BurstError'];
34
53
  export type ExecInputResult = S['ExecInputResult'];
35
54
  export type OutputEvent = S['OutputEvent'];
36
55
  export type PtyOpenRequest = S['PtyOpenRequest'];
@@ -223,6 +242,10 @@ export interface RunResult {
223
242
  session: ExecSession;
224
243
  /** Output-stream reconnections performed (gateway restarts, network drops). */
225
244
  reconnects: number;
245
+ /** The memory grow the start of this exec waited for (0.13.0; elastic workspaces), null when none ran. */
246
+ memoryGrow: MemoryGrow | null;
247
+ /** The burst summary (0.13.0; `burst: 'always'`), null for an ordinary exec. */
248
+ burst: BurstSummary | null;
226
249
  }
227
250
  export interface RunOptions {
228
251
  sessionId?: string;
@@ -254,7 +277,24 @@ export interface RunOptions {
254
277
  * (details.reason `secret_not_available`, details.names) and nothing runs; a name also present in `env` is 422.
255
278
  */
256
279
  secretRefs?: string[];
280
+ /**
281
+ * Burst execution (0.13.0; needs the organization entitlement `policy.burst_exec`): `'always'` runs this
282
+ * run-to-completion command on a larger, short-lived burst VM on the workspace's
283
+ * host over a copy of the workspace disk, and applies its file changes back when it exits (`result.burst`).
284
+ * Processes it starts and tmpfs content (/tmp when tmpfs, /dev/shm) do not survive; the workspace's own processes
285
+ * pause meanwhile and writes to it are refused (409). Not with `stdin` (422 burst_not_supported). Refusals: 409
286
+ * `burst_unavailable` (details.reason not_available, layout_unsupported, host_capacity, ...), 409
287
+ * `burst_apply_failed` (details.applied_entries/pending_entries; retry with the same `sessionId` to finish the apply).
288
+ * A burst cannot be canceled: `signal` stops following it, not the command.
289
+ */
290
+ burst?: 'never' | 'always';
291
+ /** Burst VM vCPUs (1-32; default the host's, 8, or the plan's ceiling when lower). */
292
+ burstVcpus?: number;
293
+ /** Burst VM memory in MiB (512-65536; default the host's, 8192, or the plan's ceiling when lower). */
294
+ burstMemoryMib?: number;
257
295
  }
296
+ /** The API error of a burst's recorded failure (`burst.error` of its session). Internal (the agent tools use it too). */
297
+ export declare function burstFailure(e: BurstError): ShardfluxApiError;
258
298
  /** Parses an NDJSON byte stream into objects (tolerates CRLF and a final unterminated line). */
259
299
  export declare function ndjson<T>(body: ReadableStream<Uint8Array>): AsyncGenerator<T>;
260
300
  export declare class CellClient {
@@ -308,6 +348,10 @@ export declare class CellClient {
308
348
  signal?: AbortSignal;
309
349
  }) => Promise<ExecInputResult>;
310
350
  signal: (sessionId: string, signal: Signal, onlyLeader?: boolean) => Promise<ExecSession>;
351
+ /**
352
+ * SIGTERM to the process group, SIGKILL after `graceMs` (0 to 60000; 0 or omitted is 5000). Resolves once the
353
+ * command has ended; the request waits for the grace.
354
+ */
311
355
  cancel: (sessionId: string, graceMs?: number) => Promise<ExecSession>;
312
356
  /**
313
357
  * Starts (or re-attaches to) a session and collects its output until it exits, reconnecting
package/dist/cell.js CHANGED
@@ -2,6 +2,9 @@ import { ExecStartError, NotSupportedForModeError, ShardfluxApiError, ShardfluxP
2
2
  import { EXECUTION_ID, ExecutionResult, newExecutionId } from "./executions.js";
3
3
  import { HttpClient, defaultSleep, randomId, treeRevisionOf } from "./http.js";
4
4
  import { describeFailure, emitTo } from "./progress.js";
5
+ /** Error codes of a burst's own failure: the burst's terminal outcome, never a dropped stream. */
6
+ const BURST_FAILURES = new Set(['burst_unavailable', 'burst_apply_failed']);
7
+ const TERMINAL = Symbol('shardflux.terminal');
5
8
  /** Fills a path template that must exist in cell-api.yaml. */
6
9
  export function cellPath(template, params) {
7
10
  return template.replace(/\{([a-z_]+)\}/g, (_, name) => {
@@ -19,6 +22,13 @@ export const DEFAULT_TRANSITION_TIMEOUT_MS = 120_000;
19
22
  export const CAPTURE_BARRIER = Symbol('shardflux.captureBarrier');
20
23
  /** Wakes per call at most: a workspace that keeps being suspended again surfaces the refusal. */
21
24
  const MAX_WAKES = 3;
25
+ /** exec.cancel grace: the workspace waits 5 s without one and takes at most 60 s. */
26
+ const DEFAULT_CANCEL_GRACE_MS = 5_000;
27
+ const MAX_CANCEL_GRACE_MS = 60_000;
28
+ /** The API error of a burst's recorded failure (`burst.error` of its session). Internal (the agent tools use it too). */
29
+ export function burstFailure(e) {
30
+ return new ShardfluxApiError(409, { error: { code: e.code, message: e.message, request_id: '', retryable: e.retryable, ...(e.details ? { details: e.details } : {}) } }, 'cell');
31
+ }
22
32
  const b64 = (bytes) => Buffer.from(typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : bytes).toString('base64');
23
33
  const unb64 = (s) => (s ? new Uint8Array(Buffer.from(s, 'base64')) : new Uint8Array());
24
34
  /** Parses an NDJSON byte stream into objects (tolerates CRLF and a final unterminated line). */
@@ -349,11 +359,18 @@ export class CellClient {
349
359
  return Promise.reject(refusal);
350
360
  return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/signal', { session_id: sessionId }), { json: { signal, only_leader: onlyLeader } });
351
361
  },
362
+ /**
363
+ * SIGTERM to the process group, SIGKILL after `graceMs` (0 to 60000; 0 or omitted is 5000). Resolves once the
364
+ * command has ended; the request waits for the grace.
365
+ */
352
366
  cancel: (sessionId, graceMs) => {
353
367
  const refusal = this.#noSessions('exec.cancel');
354
368
  if (refusal)
355
369
  return Promise.reject(refusal);
356
- return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/cancel', { session_id: sessionId }), { json: graceMs === undefined ? {} : { grace_ms: graceMs } });
370
+ return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/cancel', { session_id: sessionId }), {
371
+ json: graceMs === undefined ? {} : { grace_ms: graceMs },
372
+ timeoutMs: Math.max(this.#opts.timeoutMs, (graceMs || DEFAULT_CANCEL_GRACE_MS) + 15_000),
373
+ });
357
374
  },
358
375
  /**
359
376
  * Starts (or re-attaches to) a session and collects its output until it exits, reconnecting
@@ -382,21 +399,53 @@ export class CellClient {
382
399
  req.kill_grace_ms = opts.killGraceMs;
383
400
  if (opts.secretRefs !== undefined)
384
401
  req.secret_refs = opts.secretRefs;
385
- const started = await this.exec.start(req, opts.signal);
386
- if (started.state === 'failed_to_start')
387
- throw new ExecStartError(started);
402
+ if (opts.burst !== undefined)
403
+ req.burst = opts.burst;
404
+ if (opts.burstVcpus !== undefined)
405
+ req.burst_vcpus = opts.burstVcpus;
406
+ if (opts.burstMemoryMib !== undefined)
407
+ req.burst_memory_mib = opts.burstMemoryMib;
408
+ const burst = opts.burst === 'always';
409
+ // One request starts the command and follows its output (0.13.0+). Each attempt's response headers are bounded
410
+ // like the old start request; the output stream then runs as long as the command. `initialAbort` ends that
411
+ // stream once it is drained.
412
+ const initialAbort = new AbortController();
413
+ const signal = opts.signal ? AbortSignal.any([opts.signal, initialAbort.signal]) : initialAbort.signal;
414
+ const res = await this.request('POST', this.#p('/v1/workspaces/{workspace_id}/exec'), {
415
+ json: req, accept: 'application/x-ndjson', timeoutMs: 0, headersTimeoutMs: this.#opts.timeoutMs, signal,
416
+ });
417
+ const streamed = res.headers.get('content-type')?.includes('application/x-ndjson') === true;
418
+ let started;
419
+ if (!streamed) {
420
+ // A cell without the combined start, and a start that failed, answer with the session JSON: start, then output.
421
+ const text = await res.text();
422
+ try {
423
+ started = JSON.parse(text);
424
+ }
425
+ catch {
426
+ throw new ShardfluxProtocolError('POST exec: response is not JSON', res.status, 'cell');
427
+ }
428
+ if (started.state === 'failed_to_start')
429
+ throw started.burst?.error ? burstFailure(started.burst.error) : new ExecStartError(started);
430
+ }
388
431
  try {
389
- return await this.#collect(sessionId, opts);
432
+ const r = await this.#collect(sessionId, opts, streamed ? { response: res, abort: initialAbort } : undefined);
433
+ // The start's session carries the grow (a combined stream carries it on its exit event); later reads may not.
434
+ const grow = started?.memory_grow ?? r.session.memory_grow;
435
+ return grow ? { ...r, memoryGrow: grow } : r;
390
436
  }
391
437
  catch (e) {
392
438
  // Aborting the caller must stop the command too, not only our HTTP calls (MCP cancellation, timeouts):
393
- // best-effort cancel (SIGTERM, SIGKILL after the grace), bounded so the abort stays prompt.
394
- if (opts.signal?.aborted && opts.cancelOnAbort !== false) {
439
+ // best-effort cancel (SIGTERM, SIGKILL after the grace), bounded so the abort stays prompt. A burst cannot be
440
+ // canceled (it runs to its end or its timeout).
441
+ if (opts.signal?.aborted && opts.cancelOnAbort !== false && !burst) {
395
442
  let timer;
396
443
  const bound = new Promise((resolve) => {
397
444
  timer = setTimeout(resolve, 5_000);
398
445
  });
399
- await Promise.race([this.exec.cancel(sessionId, opts.killGraceMs).then(() => undefined, () => undefined), bound]);
446
+ // killGraceMs goes up to 600000; a cancel takes at most 60000.
447
+ const grace = opts.killGraceMs === undefined ? undefined : Math.min(opts.killGraceMs, MAX_CANCEL_GRACE_MS);
448
+ await Promise.race([this.exec.cancel(sessionId, grace).then(() => undefined, () => undefined), bound]);
400
449
  clearTimeout(timer);
401
450
  }
402
451
  throw e;
@@ -404,7 +453,7 @@ export class CellClient {
404
453
  },
405
454
  };
406
455
  /** exec.run's output loop: offsets, reconnects, exit. */
407
- async #collect(sessionId, opts) {
456
+ async #collect(sessionId, opts, initial) {
408
457
  let session;
409
458
  const max = opts.maxOutputBytes ?? 1_048_576;
410
459
  const out = new ByteSink(max);
@@ -415,11 +464,15 @@ export class CellClient {
415
464
  const maxReconnects = opts.maxReconnects ?? 10;
416
465
  for (;;) {
417
466
  let exited;
418
- const streamAbort = new AbortController();
467
+ const first = initial;
468
+ initial = undefined; // A dropped combined response reconnects only by GET, never re-starting the command.
469
+ const streamAbort = first?.abort ?? new AbortController();
419
470
  const signal = opts.signal ? AbortSignal.any([opts.signal, streamAbort.signal]) : streamAbort.signal;
420
471
  let drainTimer;
421
472
  try {
422
- const events = await this.exec.output(sessionId, { stdoutOffset: so, stderrOffset: se, follow: true, signal });
473
+ if (first && !first.response.body)
474
+ throw new ShardfluxProtocolError('exec output: empty body', first.response.status, 'cell');
475
+ const events = first ? ndjson(first.response.body) : await this.exec.output(sessionId, { stdoutOffset: so, stderrOffset: se, follow: true, signal });
423
476
  for await (const ev of events) {
424
477
  if (exited)
425
478
  continue;
@@ -447,12 +500,15 @@ export class CellClient {
447
500
  drainTimer = setTimeout(() => streamAbort.abort(), 250);
448
501
  }
449
502
  else if (ev.type === 'error' && ev.error) {
503
+ // A burst's failure is its terminal outcome, not a dropped stream: never reconnect.
504
+ if (BURST_FAILURES.has(ev.error.error.code))
505
+ throw Object.assign(new ShardfluxApiError(409, ev.error, 'cell'), { [TERMINAL]: true });
450
506
  throw new ShardfluxApiError(502, ev.error, 'cell');
451
507
  }
452
508
  }
453
509
  }
454
510
  catch (e) {
455
- if (opts.signal?.aborted || this.closed)
511
+ if (opts.signal?.aborted || this.closed || (e instanceof ShardfluxApiError && TERMINAL in e))
456
512
  throw e;
457
513
  const transient = !(e instanceof ShardfluxApiError) || e.retryable;
458
514
  if (!exited && (!transient || reconnects >= maxReconnects))
@@ -478,6 +534,9 @@ export class CellClient {
478
534
  break;
479
535
  }
480
536
  }
537
+ // A burst that failed after its start answered (the session ended with burst.error).
538
+ if (session.burst?.error)
539
+ throw burstFailure(session.burst.error);
481
540
  // A start answered while the session was still starting can end without starting the command.
482
541
  if (session.state === 'failed_to_start')
483
542
  throw new ExecStartError(session);
@@ -494,6 +553,8 @@ export class CellClient {
494
553
  truncated: out.total > out.kept || err.total > err.kept,
495
554
  session,
496
555
  reconnects,
556
+ memoryGrow: null,
557
+ burst: session.burst ?? null,
497
558
  };
498
559
  }
499
560
  // ---- executions (file-first workspaces) -----------------------------
package/dist/client.d.ts CHANGED
@@ -22,7 +22,7 @@ import type { SaveAsTemplateParams, SaveAsTemplateResponse } from './templates.j
22
22
  import { UsageApi } from './usage.js';
23
23
  import { VolumesApi } from './volumes.js';
24
24
  import type { VersionCheckOption } from './version-check.js';
25
- import type { FinishedOperation, InternalLifecycleOptions, LifecycleOptions, ResumeOptions, SuspendOptions, WaitedLifecycleOptions, WaitedResumeOptions, WaitedSuspendOptions } from './lifecycle.js';
25
+ import type { FinishedOperation, ForkOptions, InternalLifecycleOptions, LifecycleOptions, ResumeOptions, SuspendOptions, WaitedForkOptions, WaitedLifecycleOptions, WaitedResumeOptions, WaitedSuspendOptions } from './lifecycle.js';
26
26
  import type { ProgressListener } from './progress.js';
27
27
  import { CaptureRegistry } from './capture.js';
28
28
  import type { FeedbackReceipt, SendFeedbackParams } from './feedback.js';
@@ -34,8 +34,6 @@ export type WorkspaceLifetime = components['schemas']['WorkspaceLifetime'];
34
34
  export type DiskLayout = components['schemas']['DiskLayout'];
35
35
  /** standard, template_draft (a template's dev-mode draft) or template_test (a test instance of a draft state). */
36
36
  export type WorkspacePurpose = components['schemas']['WorkspacePurpose'];
37
- /** Reserved (T2): always `pinned` in T1. */
38
- export type UpdatePolicy = components['schemas']['UpdatePolicy'];
39
37
  /** Where the workspace's disk came from: null, a fork, or a draft state (test instances). */
40
38
  export type WorkspaceOrigin = components['schemas']['WorkspaceOrigin'];
41
39
  export type ResetWorkspaceBody = components['schemas']['ResetWorkspaceBody'];
@@ -143,10 +141,35 @@ export interface ShardfluxOptions {
143
141
  */
144
142
  versionCheck?: VersionCheckOption;
145
143
  }
144
+ /**
145
+ * How a workspace holds its memory (0.13.0): `fixed` boots `memory_mib` and holds it; `elastic` makes
146
+ * `memory_mib` a promise: the VM holds `memory_mib_held` while idle and the host grows it when a command needs it.
147
+ */
148
+ export type AllocationMode = 'fixed' | 'elastic';
149
+ /**
150
+ * Memory of a workspace (0.13.0): `promised_mib` (what it may grow to), `held_mib` (the idle floor;
151
+ * for a fixed workspace the same as the promise) and `plugged_mib` (plugged above the held floor on the live
152
+ * allocation, refreshed about every 60 s; null without one). `allocation_mode` is the running VM's, else the next start's.
153
+ */
154
+ export type WorkspaceMemory = WorkspaceView['memory'];
146
155
  export interface Caps {
147
156
  cpu_millis?: number;
157
+ /** Memory in MiB; for an elastic workspace the promise (what it may grow to). */
148
158
  memory_mib?: number;
149
159
  disk_gib?: number;
160
+ /**
161
+ * `fixed` (default) or `elastic` (0.13.0). Given caps replace the stored ones: caps without it make
162
+ * the workspace fixed again; omitted caps keep the stored layout. Elastic needs the organization's entitlement, else
163
+ * ShardfluxApiError 422 `validation_failed` reason `allocation_mode_not_available` (nothing is created or changed);
164
+ * a file-first workspace gets `not_supported_for_mode`. A promise that does not exceed the held floor by at least
165
+ * 512 MiB is fixed at the promise (the returned `caps.allocation_mode` says which). Takes effect at the next VM start.
166
+ */
167
+ allocation_mode?: AllocationMode;
168
+ /**
169
+ * Elastic only (0.13.0): the memory held while idle, in MiB (at least 512, default 1024 server side, at most
170
+ * `memory_mib`). Without elastic: 422 reason `requires_elastic`; above `memory_mib`: 422 `exceeds_memory_mib`.
171
+ */
172
+ memory_mib_held?: number;
150
173
  }
151
174
  export interface ForkTarget {
152
175
  key: string;
@@ -420,13 +443,14 @@ export declare class WorkspacesApi {
420
443
  /**
421
444
  * Forks into a new key. `lifetime` is the fork's own (default persistent): forking a session is how it is kept.
422
445
  * Resolves when the fork is requested (the copy's handle is returned at once); with `wait`, once the copy exists,
423
- * with its handle refreshed.
446
+ * with its handle ready. From 0.13.0, a supporting API returns the target view and final-epoch token together.
447
+ * `agentLabel`/`tools` choose that token; an older API ignoring Prefer falls back to polling and refreshing.
424
448
  */
425
- fork(workspaceId: string, target: ForkTarget, opts: WaitedLifecycleOptions): Promise<{
449
+ fork(workspaceId: string, target: ForkTarget, opts: WaitedForkOptions): Promise<{
426
450
  operation: FinishedOperation;
427
451
  workspace: Workspace;
428
452
  }>;
429
- fork(workspaceId: string, target: ForkTarget, opts?: LifecycleOptions): Promise<{
453
+ fork(workspaceId: string, target: ForkTarget, opts?: ForkOptions): Promise<{
430
454
  operation: Operation;
431
455
  workspace: Workspace;
432
456
  }>;
package/dist/client.js CHANGED
@@ -543,17 +543,45 @@ export class WorkspacesApi {
543
543
  }
544
544
  async fork(workspaceId, target, opts = {}) {
545
545
  let copy;
546
- const operation = await runLifecycle(this.#ctx(), 'fork', workspaceId, async (init) => {
547
- const out = await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/fork`, target, opts.idempotencyKey, init);
548
- copy = this.#wrap(out.workspace);
546
+ let heldReady = false;
547
+ const requestedWait = waitOptionsOf(opts);
548
+ const wait = requestedWait ? { ...requestedWait } : null;
549
+ const timeoutMs = wait?.timeoutMs ?? 300_000;
550
+ const started = Date.now();
551
+ const operation = await runLifecycle(this.#ctx(), 'fork', workspaceId, async (init, trace) => {
552
+ const waitS = wait && wait.serverWait !== false ? Math.min(SERVER_WAIT_MAX_S, Math.floor(timeoutMs / 1000)) : 0;
553
+ const body = { ...target };
554
+ if (waitS >= 1 && opts.agentLabel !== undefined)
555
+ body.agent_label = opts.agentLabel;
556
+ if (waitS >= 1 && opts.tools !== undefined)
557
+ body.tools = [...opts.tools];
558
+ const res = await this.#http.jsonWithHeaders('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/fork`, {
559
+ ...init, json: body, idempotencyKey: opts.idempotencyKey ?? randomId('op-'),
560
+ ...(wait?.signal ? { signal: wait.signal } : {}),
561
+ ...(waitS >= 1 ? { headers: { prefer: `wait=${waitS}` }, timeoutMs: Math.max(this.#http.opts.timeoutMs, waitS * 1000 + 10_000) } : {}),
562
+ }, this.#auth);
563
+ const out = res.body;
564
+ if (!out?.workspace || !out.operation)
565
+ throw new ShardfluxProtocolError('fork: response has no workspace or operation', res.status, 'api');
566
+ heldReady = waitS >= 1 && res.status === 200 && /\bwait\s*=/i.test(res.headers.get('preference-applied') ?? '');
567
+ if (heldReady && (out.operation.state !== 'succeeded' || out.workspace.observed_state !== 'running')) {
568
+ throw new ShardfluxProtocolError('fork: held response is not a succeeded running target', res.status, 'api');
569
+ }
570
+ const token = heldReady ? out.tool_token ?? null : null;
571
+ this.#noteToken(token);
572
+ copy = this.#wrap(out.workspace, { agentLabel: opts.agentLabel, tools: opts.tools, token, trace });
573
+ if (wait && waitS >= 1)
574
+ wait.timeoutMs = Math.max(1, timeoutMs - (Date.now() - started));
549
575
  return out.operation;
550
576
  }, {
551
577
  ...opts,
578
+ ...(wait ? { wait } : {}),
552
579
  [AFTER_WAIT]: async (trace, op) => {
553
- await trace.span('view', () => copy.refresh());
580
+ if (!heldReady)
581
+ await trace.span('view', () => copy.refresh());
554
582
  await opts[AFTER_WAIT]?.(trace, op);
555
583
  },
556
- }, { settle: true });
584
+ }, { settle: true, requestReason: wait && wait.serverWait !== false && timeoutMs >= 1000 ? 'held' : null });
557
585
  return { operation, workspace: copy };
558
586
  }
559
587
  /** The workspace's text inputs `{NAME: value}` (0.7.0). Secret inputs are bound secrets, never listed. */