@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 +110 -0
- package/README.md +93 -3
- package/dist/cell.d.ts +44 -0
- package/dist/cell.js +73 -12
- package/dist/client.d.ts +30 -6
- package/dist/client.js +33 -5
- package/dist/errors.d.ts +21 -2
- package/dist/generated/app-api.d.ts +328 -47
- package/dist/generated/cell-api.d.ts +167 -9
- package/dist/http.d.ts +6 -1
- package/dist/http.js +12 -3
- package/dist/index.d.ts +4 -4
- package/dist/lifecycle.d.ts +7 -1
- package/dist/lifecycle.js +2 -2
- package/dist/templates.d.ts +25 -3
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +275 -7
- package/dist/workspace.d.ts +19 -4
- package/dist/workspace.js +21 -0
- package/package.json +1 -1
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()`
|
|
112
|
-
|
|
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.
|
|
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 }), {
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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?:
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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. */
|