@shardflux/sdk 0.13.1 → 0.15.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/dist/cell.d.ts CHANGED
@@ -30,6 +30,12 @@ import type { RequestOptions } from './http.js';
30
30
  import type { ProgressListener } from './progress.js';
31
31
  import type { ToolTokenManager } from './tokens.js';
32
32
  type S = components['schemas'];
33
+ /**
34
+ * How much memory a command needs at its start (0.14.0; `resource_hint` of an exec start): `heavy` grows an elastic
35
+ * workspace's memory before the command starts (a build, a test suite, a package install), `light` starts it at once,
36
+ * `auto` (the default) decides from the command. A fixed workspace has its memory already.
37
+ */
38
+ export type ResourceHint = NonNullable<S['ExecStartRequest']['resource_hint']>;
33
39
  export type ExecStartRequest = S['ExecStartRequest'];
34
40
  export type ExecSession = S['ExecSession'];
35
41
  /**
@@ -141,6 +147,16 @@ export type GitCommitRequest = S['GitCommitRequest'];
141
147
  export type GitResult = S['GitResult'];
142
148
  export type GitStatus = S['GitStatus'];
143
149
  export type BrowserScreenshotRequest = S['BrowserScreenshotRequest'];
150
+ /** Contracts §45 (0.15.0+): the workspace desktop. */
151
+ export type ComputerAction = S['ComputerAction'];
152
+ export type ComputerActionName = ComputerAction['action'];
153
+ export type ComputerActionsRequest = S['ComputerActionsRequest'];
154
+ export type ComputerActionsResult = S['ComputerActionsResult'];
155
+ export type ComputerActionResult = S['ComputerActionResult'];
156
+ export type ComputerImage = S['ComputerImage'];
157
+ export type ComputerStatus = S['ComputerStatus'];
158
+ export type ComputerStartRequest = S['ComputerStartRequest'];
159
+ export type ComputerStreamInfo = S['ComputerStream'];
144
160
  export type BrowserContentRequest = S['BrowserContentRequest'];
145
161
  export type BrowserContent = S['BrowserContent'];
146
162
  export type Signal = S['SignalValue'];
@@ -293,6 +309,14 @@ export interface RunOptions {
293
309
  burstVcpus?: number;
294
310
  /** Burst VM memory in MiB (512-65536; default the host's, 8192, or the plan's ceiling when lower). */
295
311
  burstMemoryMib?: number;
312
+ /**
313
+ * How much memory the command needs at its start (0.14.0; sent as `resource_hint`, omitted when unset): `'heavy'`
314
+ * grows an elastic workspace's memory before the command starts, so a build, test suite or install sizes its heap and
315
+ * workers from the memory it gets; `'light'` starts it at once and the workspace grows while it runs; `'auto'` (the
316
+ * default) decides from the command (package installers, test runners, compilers, type checkers and bundlers are
317
+ * heavy). A value outside these is ShardfluxApiError 422 `validation_failed` (details.field `resource_hint`).
318
+ */
319
+ resourceHint?: ResourceHint;
296
320
  }
297
321
  /** The API error of a burst's recorded failure (`burst.error` of its session). Internal (the agent tools use it too). */
298
322
  export declare function burstFailure(e: BurstError): ShardfluxApiError;
@@ -516,6 +540,21 @@ export declare class CellClient {
516
540
  status: (path: string) => Promise<GitStatus>;
517
541
  commit: (req: GitCommitRequest) => Promise<GitResult>;
518
542
  };
543
+ readonly computer: {
544
+ status: () => Promise<ComputerStatus>;
545
+ start: (req?: ComputerStartRequest) => Promise<ComputerStatus>;
546
+ stop: () => Promise<void>;
547
+ /**
548
+ * Runs a batch of actions in order (the first failure stops it; the rest are reported `skipped`), then a screenshot
549
+ * when `screenshot` is set. Waits and key holds may take up to 300 s per batch.
550
+ */
551
+ act: (req: ComputerActionsRequest, signal?: AbortSignal) => Promise<ComputerActionsResult>;
552
+ /** Starts the viewer in the guest; expose the returned port and open a link to `path` (Workspace.computer.stream does both). */
553
+ streamStart: (req?: {
554
+ interactive?: boolean;
555
+ }) => Promise<ComputerStreamInfo>;
556
+ streamStop: () => Promise<void>;
557
+ };
519
558
  readonly browser: {
520
559
  screenshot: (req: BrowserScreenshotRequest) => Promise<Uint8Array>;
521
560
  content: (req: BrowserContentRequest) => Promise<BrowserContent>;
package/dist/cell.js CHANGED
@@ -444,6 +444,8 @@ export class CellClient {
444
444
  req.kill_grace_ms = opts.killGraceMs;
445
445
  if (opts.secretRefs !== undefined)
446
446
  req.secret_refs = opts.secretRefs;
447
+ if (opts.resourceHint !== undefined)
448
+ req.resource_hint = opts.resourceHint;
447
449
  if (opts.burst !== undefined)
448
450
  req.burst = opts.burst;
449
451
  if (opts.burstVcpus !== undefined)
@@ -1063,6 +1065,75 @@ export class CellClient {
1063
1065
  return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/git/commit'), { json: req });
1064
1066
  },
1065
1067
  };
1068
+ // ---- computer (contracts §45, 0.15.0+) ---------------------------------------------------------
1069
+ /**
1070
+ * The workspace desktop. The platform starts it on the first call that needs it (actions, start, stream); status
1071
+ * never starts it. Needs the `computer` tool, which tool tokens carry while the workspace's computer use is on.
1072
+ */
1073
+ /**
1074
+ * A computer call with one retry on a fresh token when the cached one lacks the computer tool: computer use may have
1075
+ * been switched on after the token was issued (tokens live up to 15 minutes).
1076
+ */
1077
+ async #computer(call) {
1078
+ try {
1079
+ return await call();
1080
+ }
1081
+ catch (err) {
1082
+ if (!(err instanceof ShardfluxApiError && err.status === 403 && err.details?.tool === 'computer'))
1083
+ throw err;
1084
+ this.tokens.invalidate();
1085
+ return call();
1086
+ }
1087
+ }
1088
+ computer = {
1089
+ status: () => {
1090
+ const refusal = this.#needsVm('computer.status');
1091
+ if (refusal)
1092
+ return Promise.reject(refusal);
1093
+ return this.#computer(() => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/computer')));
1094
+ },
1095
+ start: (req = {}) => {
1096
+ const refusal = this.#needsVm('computer.start');
1097
+ if (refusal)
1098
+ return Promise.reject(refusal);
1099
+ return this.#computer(() => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/computer'), { json: req, timeoutMs: 90_000 }));
1100
+ },
1101
+ stop: async () => {
1102
+ const refusal = this.#needsVm('computer.stop');
1103
+ if (refusal)
1104
+ throw refusal;
1105
+ await this.#computer(() => this.#json('DELETE', this.#p('/v1/workspaces/{workspace_id}/computer')));
1106
+ },
1107
+ /**
1108
+ * Runs a batch of actions in order (the first failure stops it; the rest are reported `skipped`), then a screenshot
1109
+ * when `screenshot` is set. Waits and key holds may take up to 300 s per batch.
1110
+ */
1111
+ act: (req, signal) => {
1112
+ const refusal = this.#needsVm('computer.actions');
1113
+ if (refusal)
1114
+ return Promise.reject(refusal);
1115
+ const waits = req.actions.reduce((sum, a) => sum + (a.duration ?? 0), 0);
1116
+ const typing = req.actions.reduce((sum, a) => sum + (a.action === 'type' ? (a.text?.length ?? 0) : 0), 0);
1117
+ return this.#computer(() => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/computer/actions'), {
1118
+ json: req,
1119
+ timeoutMs: 120_000 + waits * 1000 + typing * 25,
1120
+ ...(signal ? { signal } : {}),
1121
+ }));
1122
+ },
1123
+ /** Starts the viewer in the guest; expose the returned port and open a link to `path` (Workspace.computer.stream does both). */
1124
+ streamStart: (req = {}) => {
1125
+ const refusal = this.#needsVm('computer.stream');
1126
+ if (refusal)
1127
+ return Promise.reject(refusal);
1128
+ return this.#computer(() => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/computer/stream'), { json: req, timeoutMs: 90_000 }));
1129
+ },
1130
+ streamStop: async () => {
1131
+ const refusal = this.#needsVm('computer.stream_stop');
1132
+ if (refusal)
1133
+ throw refusal;
1134
+ await this.#computer(() => this.#json('DELETE', this.#p('/v1/workspaces/{workspace_id}/computer/stream')));
1135
+ },
1136
+ };
1066
1137
  // ---- browser ---------------------------------------------------------------------------
1067
1138
  browser = {
1068
1139
  screenshot: (req) => {
package/dist/client.d.ts CHANGED
@@ -14,8 +14,11 @@ import { HttpClient } from './http.js';
14
14
  import type { RequestOptions } from './http.js';
15
15
  import type { ToolName, ToolToken } from './tokens.js';
16
16
  import { Workspace } from './workspace.js';
17
+ import { WorkspaceRef } from './workspace-ref.js';
18
+ import type { WorkspaceRefParams } from './workspace-ref.js';
17
19
  import { AuditApi } from './audit.js';
18
20
  import { EgressPolicyApi } from './egress.js';
21
+ import { WorkspacePorts } from './ports.js';
19
22
  import { SecretsApi } from './secrets.js';
20
23
  import { TemplatesApi } from './templates.js';
21
24
  import type { SaveAsTemplateParams, SaveAsTemplateResponse } from './templates.js';
@@ -27,6 +30,8 @@ import type { ProgressListener } from './progress.js';
27
30
  import { CaptureRegistry } from './capture.js';
28
31
  import type { FeedbackReceipt, SendFeedbackParams } from './feedback.js';
29
32
  export type WorkspaceView = components['schemas']['Workspace'];
33
+ /** Contracts §45.1 (0.15.0+): the workspace's computer use switch. */
34
+ export type ComputerUse = components['schemas']['ComputerUse'];
30
35
  export type Operation = components['schemas']['Operation'];
31
36
  /** persistent (kept until deleted) or session (discarded when the session ends: close(), idle timeout). */
32
37
  export type WorkspaceLifetime = components['schemas']['WorkspaceLifetime'];
@@ -115,9 +120,9 @@ export interface SuspendWhenIdleResult {
115
120
  workspace: Workspace;
116
121
  }
117
122
  export interface ShardfluxOptions {
118
- /** Project API key: sfk_<key_id>_<secret>. */
119
- apiKey: string;
120
- /** Default https://api.shardflux.dev (override with `baseUrl`). */
123
+ /** Project API key: sfk_<key_id>_<secret>. Default (0.15.0+): the `SHARDFLUX_API_KEY` environment variable. */
124
+ apiKey?: string;
125
+ /** Default (0.15.0+): `SHARDFLUX_API_URL`, else https://api.shardflux.dev. */
121
126
  baseUrl?: string;
122
127
  /** Default: pooled HTTP/1.1 on Node 26+, native fetch on other runtimes (see defaultFetch in http.ts). */
123
128
  fetch?: typeof fetch;
@@ -158,8 +163,9 @@ export interface Caps {
158
163
  memory_mib?: number;
159
164
  disk_gib?: number;
160
165
  /**
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
166
+ * `fixed` or `elastic` (0.13.0). Omitted: fixed, or elastic where that is the organization's default (a reopen then
167
+ * keeps the stored mode); the SDK never fills it in. Given caps replace the stored ones; omitted caps keep the stored
168
+ * layout. `workspace.resize()` (0.14.0+) changes it on a running or suspended workspace. Elastic needs the organization's entitlement, else
163
169
  * ShardfluxApiError 422 `validation_failed` reason `allocation_mode_not_available` (nothing is created or changed);
164
170
  * a file-first workspace gets `not_supported_for_mode`. A promise that does not exceed the held floor by at least
165
171
  * 512 MiB is fixed at the promise (the returned `caps.allocation_mode` says which). Takes effect at the next VM start.
@@ -176,6 +182,64 @@ export interface ForkTarget {
176
182
  caps?: Caps;
177
183
  lifetime?: WorkspaceLifetime;
178
184
  }
185
+ /**
186
+ * `workspace.resize()` / `workspaces.resize()` (0.14.0): the caps to change. Give at least one; the others keep their
187
+ * stored values. Each value is bounded by the plan's per-workspace maximum and the template's limit, as caps at open.
188
+ */
189
+ export interface ResizeParams {
190
+ /** Memory in MiB: the size of a fixed workspace, the maximum an elastic one may grow to. */
191
+ memoryMib?: number;
192
+ /** Elastic only: the memory the workspace holds while idle, in MiB (at most the resulting `memoryMib`). */
193
+ memoryMibHeld?: number;
194
+ /** `fixed` or `elastic`: switches the mode, live on a running workspace. */
195
+ allocationMode?: AllocationMode;
196
+ /** CPU in millicores (1000 = one vCPU). */
197
+ cpuMillis?: number;
198
+ /** Disk in GiB; disks grow only. */
199
+ diskGib?: number;
200
+ /** Replays the stored response for a repeated request (default: a fresh key per call, so transport retries replay). */
201
+ idempotencyKey?: string;
202
+ /** Give up waiting for the resize after this long (default 300 000 ms); it continues server side. */
203
+ timeoutMs?: number;
204
+ signal?: AbortSignal;
205
+ /** Progress of the resize (the held request, the operation's states, retries, `done` with the timing). */
206
+ onProgress?: ProgressListener;
207
+ }
208
+ /** The held answer of `PATCH /v1/workspaces/{id}/caps` (and a finished resize operation's `result`). */
209
+ export type ResizeResponse = components['schemas']['ResizeResult'];
210
+ /** The memory part of a resize result (0.14.0). `*_mib` are MiB; `applied_mib` is what is live after the call (null when no VM runs), `held_mib` the elastic floor, `converged` whether a live change reached its target. */
211
+ export type ResizeMemory = NonNullable<ResizeResponse['memory']>;
212
+ /** The CPU part of a resize result (0.14.0), in millicores. */
213
+ export type ResizeCpu = NonNullable<ResizeResponse['cpu']>;
214
+ /** The disk part of a resize result (0.14.0), in GiB. */
215
+ export type ResizeDisk = NonNullable<ResizeResponse['disk']>;
216
+ /** When a resized resource takes effect (0.14.0): `now`, when the suspended workspace resumes, or at its next start. */
217
+ export type ResizeAppliesAt = ResizeMemory['applies_at'];
218
+ /** Why less than requested applied: clamped to the plan or template bound, the host had no room, or the guest kept memory it uses. */
219
+ export type ResizeLimitReason = NonNullable<ResizeMemory['limit_reason']>;
220
+ /**
221
+ * Why a resource applies later than now: `suspended` (at resume), `stopped` (at the next start), `boot_vcpus` (more CPU
222
+ * than the vCPUs the workspace booted with), `region` / `below_base` / `legacy_layout` (a memory size its running VM was
223
+ * not booted for).
224
+ */
225
+ export type ResizeDeferReason = NonNullable<ResizeMemory['reason']>;
226
+ /**
227
+ * What a resize did (0.14.0), per resource the request named (the others are null): when it applies (`applies_at`),
228
+ * what was asked, the bounded target, the size before, what is live now, and why less or later.
229
+ */
230
+ export interface ResizeResult {
231
+ workspaceId: string;
232
+ operationId: string | null;
233
+ /** The workspace's state the result describes: `running`, `suspended` or `stopped`. */
234
+ state: string;
235
+ memory: ResizeMemory | null;
236
+ cpu: ResizeCpu | null;
237
+ disk: ResizeDisk | null;
238
+ /** The workspace's stored caps after the change: every later start uses them. */
239
+ caps: ResizeResponse['caps'] | null;
240
+ /** The finished `resize` operation when the API answered before it was done (202), else null. */
241
+ operation: Operation | null;
242
+ }
179
243
  export interface WaitOptions {
180
244
  /**
181
245
  * Give up waiting after this long (default 300 000 ms); the operation continues server side. A queued start
@@ -200,6 +264,12 @@ export interface OpenParams {
200
264
  /** Searchable metadata; supplied labels replace the existing map. */
201
265
  labels?: Record<string, string>;
202
266
  idlePolicy?: IdlePolicy;
267
+ /**
268
+ * Computer use (0.15.0+, contracts §45.1): true or false sets the workspace's own switch, null follows the template;
269
+ * omitted leaves it unchanged. While it is on, tool tokens carry the `computer` tool and `workspace.computer` drives
270
+ * the workspace desktop. 409 `computer_use_unavailable` when the template version cannot run a desktop.
271
+ */
272
+ computerUse?: boolean | null;
203
273
  key: string;
204
274
  template: string;
205
275
  caps?: Caps;
@@ -338,9 +408,13 @@ export declare class WorkspacesApi {
338
408
  agentLabel?: string;
339
409
  tools?: ToolName[];
340
410
  }): Promise<Workspace>;
411
+ /** The exposed ports of a workspace by id (0.14.0; the same as `workspace.ports` without reading the workspace first). */
412
+ ports(workspaceId: string): WorkspacePorts;
341
413
  /** Replace labels. An empty map clears them. */
342
414
  setLabels(workspaceId: string, labels: Record<string, string>): Promise<Workspace>;
343
415
  /** null clears the override, restoring the template or platform policy. */
416
+ /** Computer use (0.15.0+): the workspace's own switch; null follows the template. Returns the switch. */
417
+ setComputerUse(workspaceId: string, enabled: boolean | null): Promise<ComputerUse>;
344
418
  setIdlePolicy(workspaceId: string, idlePolicy: IdlePolicy | null): Promise<Workspace>;
345
419
  list(params?: ListParams): Promise<Page<Workspace>>;
346
420
  /** Iterates every page. */
@@ -412,6 +486,28 @@ export declare class WorkspacesApi {
412
486
  * is not undone: it is the workspace's `activeOperation` (resume or open the workspace instead).
413
487
  */
414
488
  cancelSuspendWhenIdle(workspaceId: string): Promise<Workspace>;
489
+ /**
490
+ * Resizes a workspace (0.14.0; `PATCH /v1/workspaces/{id}/caps`): any of memory, the held floor, the allocation mode,
491
+ * CPU and disk, of a running, suspended or stopped workspace, fixed or elastic, without a restart or a fork. The new
492
+ * caps are stored, so every later start uses them. Resolves once the resize has finished, with what applied per
493
+ * resource: `now` (live), `resume` (a suspended workspace gets it when it resumes, before its first call) or
494
+ * `next_start` (with the `reason`). The request is held by the server while the resize runs; a resize still running
495
+ * after the hold is waited for like any operation.
496
+ *
497
+ * A resize that meets another lifecycle operation (409 `operation_in_progress`: a suspend, a resume, another resize)
498
+ * waits for it and is sent again, within `timeoutMs`.
499
+ *
500
+ * Errors: ShardfluxApiError 409 `conflict` (reason `resize_not_available`, `workspace_deleted`), 422
501
+ * `validation_failed` (reason `shrink_not_supported` with `details.current_disk_gib`, `requires_elastic`,
502
+ * `exceeds_memory_mib`, `allocation_mode_not_available`, `not_supported_for_mode`). A resize that fails is its
503
+ * error: ShardfluxApiError with `operationId` when the server held the request until then, OperationFailedError when
504
+ * the SDK waited for the operation. OperationTimeoutError after `timeoutMs`. Params without a resource throw
505
+ * TypeError before any request.
506
+ *
507
+ * const r = await cloud.workspaces.resize(id, { memoryMib: 6144 });
508
+ * r.memory?.applies_at; // 'now'
509
+ */
510
+ resize(workspaceId: string, params: ResizeParams): Promise<ResizeResult>;
415
511
  /**
416
512
  * Ends a session workspace now: the workspace is deleted exactly like delete() (ended_reason
417
513
  * closed) and returns the `delete` operation (input.reason session_closed); the key then opens a NEW workspace.
@@ -501,7 +597,14 @@ export declare class Shardflux {
501
597
  readonly audit: AuditApi;
502
598
  /** Shared volumes: persistent storage attached to workspaces at a mount path. */
503
599
  readonly volumes: VolumesApi;
504
- constructor(opts: ShardfluxOptions);
600
+ /** Reads `SHARDFLUX_API_KEY` and `SHARDFLUX_API_URL` (0.15.0+) for an `apiKey` or `baseUrl` that is not passed. */
601
+ constructor(opts?: ShardfluxOptions);
602
+ /**
603
+ * A workspace named by its key (0.15.0+, contracts §46): no request until its first call, which opens the key
604
+ * (created on first use with `params.template`, resumed afterwards). `workspace(key, { template: 'default' })
605
+ * .exec('...')` is a whole integration; see `WorkspaceRef`.
606
+ */
607
+ workspace(key: string, params: WorkspaceRefParams): WorkspaceRef;
505
608
  /** The authenticated principal (the API key, its organization and project). */
506
609
  me(): Promise<Me>;
507
610
  entitlements(organizationId: string): Promise<Entitlements>;
@@ -517,4 +620,10 @@ export declare class Shardflux {
517
620
  /** Raw access to any /v1 endpoint with the SDK's authentication and error handling. */
518
621
  request<T>(method: string, path: string, init?: Parameters<HttpClient['json']>[2]): Promise<T>;
519
622
  }
623
+ /**
624
+ * A workspace named by its key, on a client from the environment (0.15.0+): `new Shardflux()` reads
625
+ * `SHARDFLUX_API_KEY` (and `SHARDFLUX_API_URL`) on the first call. `workspace(key, { template: 'default' }).exec('...')`
626
+ * creates the workspace on first use and resumes it afterwards. Use `cloud.workspace()` for a client of your own.
627
+ */
628
+ export declare function workspace(key: string, params: WorkspaceRefParams): WorkspaceRef;
520
629
  export { ShardfluxApiError };
package/dist/client.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { DurabilityLostError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
2
2
  import { HttpClient, SDK_VERSION, SERVER_WAIT_MAX_S, defaultFetch, defaultSleep, pollWithWait, randomId } from "./http.js";
3
3
  import { Workspace } from "./workspace.js";
4
+ import { WorkspaceRef } from "./workspace-ref.js";
4
5
  import { AuditApi } from "./audit.js";
5
6
  import { EgressPolicyApi } from "./egress.js";
7
+ import { WorkspacePorts } from "./ports.js";
6
8
  import { SecretsApi } from "./secrets.js";
7
9
  import { TemplatesApi, saveAsTemplateBody } from "./templates.js";
8
10
  import { UsageApi } from "./usage.js";
@@ -46,6 +48,70 @@ function alreadyRunning(answer) {
46
48
  },
47
49
  }, 'api');
48
50
  }
51
+ /** The `PATCH .../caps` body of a resize: the given caps only, snake_case. Throws TypeError when none is given. */
52
+ function resizeBody(p) {
53
+ const body = {};
54
+ if (p.memoryMib !== undefined)
55
+ body.memory_mib = p.memoryMib;
56
+ if (p.memoryMibHeld !== undefined)
57
+ body.memory_mib_held = p.memoryMibHeld;
58
+ if (p.allocationMode !== undefined)
59
+ body.allocation_mode = p.allocationMode;
60
+ if (p.cpuMillis !== undefined)
61
+ body.cpu_millis = p.cpuMillis;
62
+ if (p.diskGib !== undefined)
63
+ body.disk_gib = p.diskGib;
64
+ if (Object.keys(body).length === 0)
65
+ throw new TypeError('resize() needs at least one of memoryMib, memoryMibHeld, allocationMode, cpuMillis, diskGib');
66
+ return body;
67
+ }
68
+ const RESIZE_KEYS = {
69
+ memory: ['applies_at', 'allocation_mode', 'requested_mib', 'target_mib', 'previous_mib', 'applied_mib', 'held_mib', 'converged', 'limit_reason', 'reason'],
70
+ cpu: ['applies_at', 'requested_millis', 'target_millis', 'previous_millis', 'applied_millis', 'limit_reason', 'reason'],
71
+ disk: ['applies_at', 'requested_gib', 'target_gib', 'previous_gib', 'applied_gib', 'limit_reason', 'reason'],
72
+ };
73
+ const record = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) ? v : {});
74
+ /**
75
+ * A finished resize operation's `result` in the held answer's shape (as the API builds its 200): each resource the
76
+ * cell reported with its documented keys, a key the cell left out taken from the operation's input (target, requested)
77
+ * or null; the cell's other keys (timings, the previous values of a retried attempt) are left out.
78
+ */
79
+ function resizeResultBodyOf(op) {
80
+ const result = record(op.result);
81
+ const input = record(op.input);
82
+ const target = record(input.target);
83
+ const requested = record(input.requested);
84
+ const fallback = {
85
+ memory: { allocation_mode: target.allocation_mode, requested_mib: requested.memory_mib, target_mib: target.memory_mib, held_mib: target.memory_mib_held },
86
+ cpu: { requested_millis: requested.cpu_millis, target_millis: target.cpu_millis },
87
+ disk: { requested_gib: requested.disk_gib, target_gib: target.disk_gib },
88
+ };
89
+ const out = { workspace_id: result.workspace_id ?? op.workspace_id, operation_id: op.id, state: result.state };
90
+ for (const [name, keys] of Object.entries(RESIZE_KEYS)) {
91
+ if (result[name] === null || typeof result[name] !== 'object')
92
+ continue;
93
+ const r = record(result[name]);
94
+ out[name] = Object.fromEntries(keys.map((k) => [k, r[k] ?? fallback[name]?.[k] ?? null]));
95
+ }
96
+ return out;
97
+ }
98
+ /** A resize result body (the held 200, or a finished operation's `result`) as ResizeResult. */
99
+ function resizeResultOf(body, status, operation, workspaceId) {
100
+ if (!body || typeof body !== 'object' || !['memory', 'cpu', 'disk'].some((k) => body[k] && typeof body[k] === 'object')) {
101
+ throw new ShardfluxProtocolError('resize: the result names no resource (memory, cpu or disk)', status, 'api');
102
+ }
103
+ const part = (k) => (body[k] && typeof body[k] === 'object' ? body[k] : null);
104
+ return {
105
+ workspaceId: typeof body.workspace_id === 'string' ? body.workspace_id : workspaceId,
106
+ operationId: typeof body.operation_id === 'string' ? body.operation_id : (operation?.id ?? null),
107
+ state: typeof body.state === 'string' ? body.state : 'unknown',
108
+ memory: part('memory'),
109
+ cpu: part('cpu'),
110
+ disk: part('disk'),
111
+ caps: body.caps && typeof body.caps === 'object' ? body.caps : null,
112
+ operation,
113
+ };
114
+ }
49
115
  /** Races the injected sleep against the signal (the injected sleep itself may not be abortable). */
50
116
  function abortableSleep(sleep, ms, signal) {
51
117
  if (signal.aborted)
@@ -100,6 +166,8 @@ export class WorkspacesApi {
100
166
  body.labels = params.labels;
101
167
  if (params.idlePolicy !== undefined)
102
168
  body.idle_policy = params.idlePolicy;
169
+ if (params.computerUse !== undefined)
170
+ body.computer_use = params.computerUse;
103
171
  if (params.lifetime !== undefined)
104
172
  body.lifetime = params.lifetime;
105
173
  if (params.mode !== undefined)
@@ -128,7 +196,9 @@ export class WorkspacesApi {
128
196
  if (res.body.operation)
129
197
  trace.observe(res.body.operation);
130
198
  this.#noteToken(res.body.tool_token);
131
- const wrapOpts = { agentLabel: params.agentLabel, tools: params.tools, token: res.body.tool_token, trace };
199
+ // An API before contracts §46.2 does not say whether the open created the workspace.
200
+ const created = res.body.created ?? null;
201
+ const wrapOpts = { agentLabel: params.agentLabel, tools: params.tools, token: res.body.tool_token, trace, created };
132
202
  if (res.status === 200 || params.wait === false || res.body.operation === null)
133
203
  return this.#wrap(res.body.workspace, wrapOpts);
134
204
  if (TERMINAL.has(res.body.operation.state)) {
@@ -316,11 +386,19 @@ export class WorkspacesApi {
316
386
  async get(workspaceId, opts = {}) {
317
387
  return this.#wrap(await this.#getView(workspaceId), opts);
318
388
  }
389
+ /** The exposed ports of a workspace by id (0.14.0; the same as `workspace.ports` without reading the workspace first). */
390
+ ports(workspaceId) {
391
+ return new WorkspacePorts(this.#ctx(), workspaceId);
392
+ }
319
393
  /** Replace labels. An empty map clears them. */
320
394
  async setLabels(workspaceId, labels) {
321
395
  return this.#wrap(await this.#http.json('PUT', `/v1/workspaces/${encodeURIComponent(workspaceId)}/labels`, { json: { labels } }, this.#auth));
322
396
  }
323
397
  /** null clears the override, restoring the template or platform policy. */
398
+ /** Computer use (0.15.0+): the workspace's own switch; null follows the template. Returns the switch. */
399
+ async setComputerUse(workspaceId, enabled) {
400
+ return this.#http.json('PUT', `/v1/workspaces/${encodeURIComponent(workspaceId)}/computer-use`, { json: { enabled } }, this.#auth);
401
+ }
324
402
  async setIdlePolicy(workspaceId, idlePolicy) {
325
403
  return this.#wrap(await this.#http.json('PUT', `/v1/workspaces/${encodeURIComponent(workspaceId)}/idle-policy`, { json: { idle_policy: idlePolicy } }, this.#auth));
326
404
  }
@@ -514,6 +592,85 @@ export class WorkspacesApi {
514
592
  async cancelSuspendWhenIdle(workspaceId) {
515
593
  return this.#wrap(await this.#http.json('DELETE', `/v1/workspaces/${encodeURIComponent(workspaceId)}/suspend-when-idle`, {}, this.#auth));
516
594
  }
595
+ /**
596
+ * Resizes a workspace (0.14.0; `PATCH /v1/workspaces/{id}/caps`): any of memory, the held floor, the allocation mode,
597
+ * CPU and disk, of a running, suspended or stopped workspace, fixed or elastic, without a restart or a fork. The new
598
+ * caps are stored, so every later start uses them. Resolves once the resize has finished, with what applied per
599
+ * resource: `now` (live), `resume` (a suspended workspace gets it when it resumes, before its first call) or
600
+ * `next_start` (with the `reason`). The request is held by the server while the resize runs; a resize still running
601
+ * after the hold is waited for like any operation.
602
+ *
603
+ * A resize that meets another lifecycle operation (409 `operation_in_progress`: a suspend, a resume, another resize)
604
+ * waits for it and is sent again, within `timeoutMs`.
605
+ *
606
+ * Errors: ShardfluxApiError 409 `conflict` (reason `resize_not_available`, `workspace_deleted`), 422
607
+ * `validation_failed` (reason `shrink_not_supported` with `details.current_disk_gib`, `requires_elastic`,
608
+ * `exceeds_memory_mib`, `allocation_mode_not_available`, `not_supported_for_mode`). A resize that fails is its
609
+ * error: ShardfluxApiError with `operationId` when the server held the request until then, OperationFailedError when
610
+ * the SDK waited for the operation. OperationTimeoutError after `timeoutMs`. Params without a resource throw
611
+ * TypeError before any request.
612
+ *
613
+ * const r = await cloud.workspaces.resize(id, { memoryMib: 6144 });
614
+ * r.memory?.applies_at; // 'now'
615
+ */
616
+ async resize(workspaceId, params) {
617
+ const body = resizeBody(params);
618
+ const trace = new Trace('resize', combineListeners(this.#ctx().onProgress, params.onProgress), { workspaceId });
619
+ return traced(trace, async () => {
620
+ const timeoutMs = params.timeoutMs ?? 300_000;
621
+ const started = Date.now();
622
+ const left = () => Math.max(1, timeoutMs - (Date.now() - started));
623
+ const idempotencyKey = params.idempotencyKey ?? randomId('op-');
624
+ const path = `/v1/workspaces/${encodeURIComponent(workspaceId)}/caps`;
625
+ let res;
626
+ for (let attempt = 0;; attempt += 1) {
627
+ const waitS = Math.min(SERVER_WAIT_MAX_S, Math.floor((attempt === 0 ? timeoutMs : left()) / 1000));
628
+ const init = { json: body, idempotencyKey, onRetry: trace.onRetry, ...(params.signal ? { signal: params.signal } : {}) };
629
+ if (waitS >= 1) {
630
+ // Held: the server answers once the resize has finished (200) or the wait elapsed (202).
631
+ init.headers = { prefer: `wait=${waitS}` };
632
+ init.timeoutMs = Math.max(this.#http.opts.timeoutMs, waitS * 1000 + 10_000);
633
+ }
634
+ trace.phase('request', waitS >= 1 ? 'held' : attempt === 0 ? null : 'retry');
635
+ try {
636
+ res = await this.#http.jsonWithStatus('PATCH', path, init, this.#auth);
637
+ break;
638
+ }
639
+ catch (e) {
640
+ // Another lifecycle operation (a suspend, a resume, another resize) holds the workspace: the refused request
641
+ // changed nothing, so wait for that operation and send it again (bounded, within timeoutMs).
642
+ const active = e instanceof ShardfluxApiError && e.code === 'conflict' && e.reason === 'operation_in_progress' ? (e.operationId ?? e.details?.['active_operation_id']) : undefined;
643
+ if (typeof active !== 'string' || attempt >= 3 || Date.now() - started >= timeoutMs)
644
+ throw e;
645
+ trace.retry({ request: `PATCH ${path}`, attempt: attempt + 1, cause: `conflict operation_in_progress (waits for ${active})`, delayMs: 0 });
646
+ try {
647
+ await this.waitForOperation(active, { timeoutMs: left(), ...(params.signal ? { signal: params.signal } : {}), [TRACE]: trace });
648
+ }
649
+ catch (w) {
650
+ if (!(w instanceof OperationFailedError))
651
+ throw w;
652
+ }
653
+ }
654
+ }
655
+ if (res.status !== 202)
656
+ return resizeResultOf(res.body, res.status, null, workspaceId);
657
+ const operation = res.body?.operation;
658
+ if (!operation || typeof operation !== 'object')
659
+ throw new ShardfluxProtocolError('resize: 202 response has no operation', res.status, 'api');
660
+ trace.observe(operation);
661
+ let final = operation;
662
+ if (operation.state !== 'succeeded') {
663
+ if (TERMINAL.has(operation.state))
664
+ throw new OperationFailedError(operation);
665
+ const wait = { timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)), ...(params.signal ? { signal: params.signal } : {}), [TRACE]: trace };
666
+ final = await this.waitForOperation(operation.id, wait);
667
+ }
668
+ // The finished operation's result is the cell's: shaped like the held answer, whose caps come from the view.
669
+ const result = resizeResultBodyOf(final);
670
+ const view = await trace.span('view', () => this.#getView(workspaceId, trace.onRetry));
671
+ return resizeResultOf({ ...result, state: result.state ?? view.observed_state, caps: view.caps }, res.status, final, workspaceId);
672
+ });
673
+ }
517
674
  async close(workspaceId, opts = {}) {
518
675
  return (await this.closeWithView(workspaceId, opts)).operation;
519
676
  }
@@ -645,8 +802,13 @@ export class Shardflux {
645
802
  /** Shared volumes: persistent storage attached to workspaces at a mount path. */
646
803
  volumes;
647
804
  #ctx;
648
- constructor(opts) {
649
- if (!/^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/.test(opts.apiKey))
805
+ #toolGrants = new GrantsCache(() => this.me());
806
+ /** Reads `SHARDFLUX_API_KEY` and `SHARDFLUX_API_URL` (0.15.0+) for an `apiKey` or `baseUrl` that is not passed. */
807
+ constructor(opts = {}) {
808
+ const apiKey = opts.apiKey ?? envVar('SHARDFLUX_API_KEY');
809
+ if (apiKey === undefined)
810
+ throw new Error('Missing API key: pass apiKey or set SHARDFLUX_API_KEY (a project key, sfk_<key_id>_<secret>)');
811
+ if (!/^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/.test(apiKey))
650
812
  throw new Error('apiKey must be a Shardflux project key (sfk_<key_id>_<secret>)');
651
813
  const f = opts.fetch ?? defaultFetch();
652
814
  const userAgent = opts.userAgent ?? `shardflux-sdk-ts/${SDK_VERSION}`;
@@ -659,10 +821,10 @@ export class Shardflux {
659
821
  this.egress = new EgressPolicyApi(() => this.#ctx);
660
822
  this.audit = new AuditApi(() => this.#ctx);
661
823
  this.volumes = new VolumesApi(() => this.#ctx);
662
- const baseUrl = opts.baseUrl ?? 'https://api.shardflux.dev';
824
+ const baseUrl = opts.baseUrl ?? envVar('SHARDFLUX_API_URL') ?? 'https://api.shardflux.dev';
663
825
  this.#ctx = {
664
826
  http: new HttpClient({ baseUrl, fetch: f, userAgent, timeoutMs: opts.timeoutMs ?? 30_000, maxRetries: opts.maxRetries ?? 2, source: 'api', sleep, onSuccess: versionCheckHook(opts.versionCheck, baseUrl, f, userAgent) }),
665
- authorization: `Bearer ${opts.apiKey}`,
827
+ authorization: `Bearer ${apiKey}`,
666
828
  fetch: f,
667
829
  userAgent,
668
830
  sleep,
@@ -671,6 +833,14 @@ export class Shardflux {
671
833
  captures: new CaptureRegistry(),
672
834
  };
673
835
  }
836
+ /**
837
+ * A workspace named by its key (0.15.0+, contracts §46): no request until its first call, which opens the key
838
+ * (created on first use with `params.template`, resumed afterwards). `workspace(key, { template: 'default' })
839
+ * .exec('...')` is a whole integration; see `WorkspaceRef`.
840
+ */
841
+ workspace(key, params) {
842
+ return new WorkspaceRef(this.workspaces, key, params, () => this.#toolGrants.get());
843
+ }
674
844
  /** The authenticated principal (the API key, its organization and project). */
675
845
  me() {
676
846
  return this.#ctx.http.json('GET', '/v1/me', {}, this.#ctx.authorization);
@@ -694,4 +864,34 @@ export class Shardflux {
694
864
  return this.#ctx.http.json(method, path, init, this.#ctx.authorization);
695
865
  }
696
866
  }
867
+ /** The client of the module-level `workspace()`: created on first use from the environment. */
868
+ let defaultClient = null;
869
+ /**
870
+ * A workspace named by its key, on a client from the environment (0.15.0+): `new Shardflux()` reads
871
+ * `SHARDFLUX_API_KEY` (and `SHARDFLUX_API_URL`) on the first call. `workspace(key, { template: 'default' }).exec('...')`
872
+ * creates the workspace on first use and resumes it afterwards. Use `cloud.workspace()` for a client of your own.
873
+ */
874
+ export function workspace(key, params) {
875
+ defaultClient ??= new Shardflux();
876
+ return defaultClient.workspace(key, params);
877
+ }
878
+ /** An environment variable, trimmed; undefined when unset, empty or outside Node-like runtimes. */
879
+ function envVar(name) {
880
+ return globalThis.process?.env?.[name]?.trim() || undefined;
881
+ }
882
+ /** The API key's tool permissions (GET /v1/me), read once; a failed read is not kept. Null for a non-key principal. */
883
+ class GrantsCache {
884
+ #load;
885
+ #value = null;
886
+ constructor(load) {
887
+ this.#load = load;
888
+ }
889
+ get() {
890
+ this.#value ??= this.#load().then((me) => (me.api_key ? [...me.api_key.tool_permissions] : null), (err) => {
891
+ this.#value = null;
892
+ throw err;
893
+ });
894
+ return this.#value;
895
+ }
896
+ }
697
897
  export { ShardfluxApiError };