@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/dist/tools.js CHANGED
@@ -10,8 +10,8 @@
10
10
  * application; toOpenAITools/toAnthropicTools export the definitions in those
11
11
  * providers' formats and executeToolCall dispatches a model's tool call.
12
12
  */
13
- import { CAPTURE_BARRIER } from "./cell.js";
14
- import { ExecStartError } from "./errors.js";
13
+ import { CAPTURE_BARRIER, burstFailure } from "./cell.js";
14
+ import { ExecStartError, ShardfluxApiError } from "./errors.js";
15
15
  import { newExecutionId } from "./executions.js";
16
16
  export class ToolArgumentError extends Error {
17
17
  tool;
@@ -98,6 +98,144 @@ function clip(text, max) {
98
98
  return { text, truncated: false };
99
99
  return { text: bytes.subarray(0, max).toString('utf8'), truncated: true };
100
100
  }
101
+ const toolError = (e) => ({ code: e.code, message: e.message, reason: e.reason });
102
+ /**
103
+ * Why a session ended without running to an exit code, as the exec tools report it: a burst's recorded failure, a
104
+ * command that could not start (as ExecStartError says it), or the session's own reason (e.g. lost). Null otherwise.
105
+ */
106
+ function sessionError(s) {
107
+ if (s.burst?.error)
108
+ return toolError(burstFailure(s.burst.error));
109
+ if (s.state === 'failed_to_start')
110
+ return toolError(new ExecStartError(s));
111
+ if (s.error)
112
+ return { code: 'internal_error', message: `The command ended without an exit status: ${s.error}`, reason: undefined };
113
+ return null;
114
+ }
115
+ const running = (s) => s.state === 'starting' || s.state === 'running';
116
+ /** A UTF-8 continuation byte (10xxxxxx). */
117
+ const continuation = (b) => (b & 0xc0) === 0x80;
118
+ /** The length of the UTF-8 sequence a lead byte starts (0 for a byte that cannot start one). */
119
+ const sequenceLength = (b) => (b < 0x80 ? 1 : (b & 0xe0) === 0xc0 ? 2 : (b & 0xf0) === 0xe0 ? 3 : (b & 0xf8) === 0xf0 ? 4 : 0);
120
+ /** `end`, moved back to the start of the UTF-8 sequence it would cut (unchanged when it cuts none). */
121
+ function utf8End(bytes, end) {
122
+ for (let i = end - 1; i >= Math.max(0, end - 3); i--) {
123
+ const b = bytes[i];
124
+ if (continuation(b))
125
+ continue;
126
+ const n = sequenceLength(b);
127
+ return n > 0 && i + n > end ? i : end;
128
+ }
129
+ return end;
130
+ }
131
+ /**
132
+ * One stream of an exec_read: the bytes from `start`, kept up to the tools' limit plus one byte (which says that more
133
+ * follows). Chunks are byte ranges of the stream (`offset` is the stream offset of their first byte), split anywhere,
134
+ * also inside a character: the text is decoded once, from the bytes joined.
135
+ */
136
+ class OutputWindow {
137
+ start;
138
+ #max;
139
+ #chunks = [];
140
+ #kept = 0;
141
+ /** How far the stream is known to reach: the session's size, or the end of a chunk seen after it. */
142
+ #end;
143
+ constructor(start, max, size) {
144
+ this.start = start;
145
+ this.#max = max;
146
+ this.#end = size;
147
+ }
148
+ get full() {
149
+ return this.#kept > this.#max;
150
+ }
151
+ push(offset, data) {
152
+ this.#end = Math.max(this.#end, offset + data.length);
153
+ const at = this.start + this.#kept;
154
+ if (this.full || offset > at || offset + data.length <= at)
155
+ return;
156
+ const part = data.subarray(at - offset, at - offset + this.#max + 1 - this.#kept);
157
+ this.#chunks.push(part);
158
+ this.#kept += part.length;
159
+ }
160
+ /**
161
+ * The text from the first character boundary at or after `start` (`offset`) to `next`: at most the limit, never a
162
+ * cut character (the cut moves back to its first byte, and a running command's last, incomplete character waits for
163
+ * the next read). `truncated`: the stream holds more than the limit past `start`.
164
+ */
165
+ result(ended) {
166
+ const bytes = Buffer.concat(this.#chunks);
167
+ let skip = 0;
168
+ if (this.start > 0)
169
+ while (skip < 3 && skip < bytes.length && continuation(bytes[skip]))
170
+ skip += 1;
171
+ const truncated = this.#end - this.start > this.#max;
172
+ let end = Math.min(bytes.length, this.#max);
173
+ if (truncated || !ended) {
174
+ const cut = utf8End(bytes, end);
175
+ // A limit too small for one character still makes progress.
176
+ end = cut > skip || !truncated ? Math.max(cut, skip) : Math.max(end, skip);
177
+ }
178
+ return { text: new TextDecoder().decode(bytes.subarray(skip, end)), offset: this.start + skip, next: this.start + end, truncated };
179
+ }
180
+ }
181
+ /** A failure event of an exec output stream: a burst's recorded outcome (on its session too), or a stream failure. */
182
+ const BURST_FAILURES = new Set(['burst_unavailable', 'burst_apply_failed']);
183
+ /**
184
+ * Reads the output that exists (follow=false) into the windows given, stopping once they are full. Returns the
185
+ * session of an `exit` event (the session ended meanwhile), else null.
186
+ */
187
+ async function drainOutput(c, id, from, into, signal) {
188
+ const windows = [into.stdout, into.stderr].filter((w) => w !== undefined);
189
+ for await (const ev of await c.exec.output(id, { stdoutOffset: from.stdout, stderrOffset: from.stderr, follow: false, ...(signal ? { signal } : {}) })) {
190
+ if (ev.type === 'output' && ev.data !== undefined) {
191
+ const w = ev.stream === 'stderr' ? into.stderr : into.stdout;
192
+ w?.push(ev.offset ?? 0, Buffer.from(ev.data, 'base64'));
193
+ if (windows.every((x) => x.full))
194
+ return null;
195
+ }
196
+ else if (ev.type === 'exit' && ev.session) {
197
+ return ev.session;
198
+ }
199
+ else if (ev.type === 'error' && ev.error && !BURST_FAILURES.has(ev.error.error.code)) {
200
+ throw new ShardfluxApiError(502, ev.error, 'cell');
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+ /**
206
+ * Waits up to `waitMs` for a running session to end and returns it as it is then. Follows the output from the current
207
+ * sizes, so only what the command writes meanwhile flows, and answers on the `exit` event; a dropped stream is
208
+ * followed again while time is left.
209
+ */
210
+ async function waitForExit(c, s, waitMs, signal) {
211
+ const deadline = Date.now() + waitMs;
212
+ let current = s;
213
+ for (let attempt = 0; attempt < 5 && running(current) && Date.now() < deadline; attempt++) {
214
+ const timer = new AbortController();
215
+ const t = setTimeout(() => timer.abort(), Math.max(0, deadline - Date.now()));
216
+ const sig = signal ? AbortSignal.any([signal, timer.signal]) : timer.signal;
217
+ try {
218
+ for await (const ev of await c.exec.output(current.session_id, { stdoutOffset: current.stdout_size, stderrOffset: current.stderr_size, follow: true, signal: sig })) {
219
+ if (ev.type === 'exit')
220
+ return ev.session ?? (await c.exec.get(current.session_id));
221
+ if (ev.type === 'error')
222
+ break;
223
+ }
224
+ }
225
+ catch (e) {
226
+ if (signal?.aborted)
227
+ throw e;
228
+ // A refusal (e.g. an unknown session) is the answer; the end of the wait or a dropped stream is not.
229
+ if (!timer.signal.aborted && e instanceof ShardfluxApiError && !e.retryable)
230
+ throw e;
231
+ }
232
+ finally {
233
+ clearTimeout(t);
234
+ }
235
+ current = await c.exec.get(current.session_id);
236
+ }
237
+ return current;
238
+ }
101
239
  /** Builds the tool list for a workspace. Synchronous: tokens are fetched on first use. */
102
240
  export function workspaceTools(workspace, opts = {}) {
103
241
  const cell = () => workspace.cell({
@@ -117,6 +255,30 @@ export function workspaceTools(workspace, opts = {}) {
117
255
  timeout_ms: { type: 'integer', minimum: 1000, maximum: 3_600_000, description: 'Kill the command after this long (default 600000).' },
118
256
  stdin: { type: 'string', maxLength: 1_000_000, description: 'Text written to stdin.' },
119
257
  }, ['command']);
258
+ // The processful exec: background sessions (0.13.0; read with exec_read, stopped with exec_cancel), a timeout up to a
259
+ // day, and with burst execution (opt-in) its extra inputs.
260
+ const sessionId = { type: 'string', minLength: 1, maxLength: 64 };
261
+ const processfulExecParameters = obj({
262
+ command: execParameters.properties.command,
263
+ cwd: execParameters.properties.cwd,
264
+ timeout_ms: { type: 'integer', minimum: 1000, maximum: 86_400_000, description: 'Kill the command after this long (default 600000; with background, none unless set).' },
265
+ stdin: execParameters.properties.stdin,
266
+ background: {
267
+ type: 'boolean',
268
+ description: 'Start the command and return its session_id at once instead of waiting for it to exit (default false). Use it for anything that may run longer than a few minutes, such as a build, a test suite, a training run or a server. Keep using the other tools meanwhile, read it with exec_read and stop it with exec_cancel. Set timeout_ms to the longest it may run: the workspace stays awake until the command ends or that time passes (1 hour when unset).',
269
+ },
270
+ ...(opts.burst
271
+ ? {
272
+ burst: {
273
+ type: 'string',
274
+ enum: ['never', 'always'],
275
+ description: 'always: run this heavy, self-terminating command (a cold build, a large test suite) on a larger, short-lived burst VM over a copy of the workspace, and apply its file changes back when it exits. Processes it starts do not survive; not with stdin. Default never.',
276
+ },
277
+ burst_vcpus: { type: 'integer', minimum: 1, maximum: 32, description: 'With burst always: the burst VM’s vCPUs (default 8, or the plan’s ceiling when lower).' },
278
+ burst_memory_mib: { type: 'integer', minimum: 512, maximum: 65536, description: 'With burst always: the burst VM’s memory in MiB (default 8192, or the plan’s ceiling when lower).' },
279
+ }
280
+ : {}),
281
+ }, ['command']);
120
282
  const defs = [
121
283
  fileFirst
122
284
  ? {
@@ -158,15 +320,42 @@ export function workspaceTools(workspace, opts = {}) {
158
320
  : {
159
321
  name: 'exec',
160
322
  permission: 'exec',
161
- description: 'Run a shell command in the persistent remote workspace (Linux; bash -lc) and return its exit code, stdout and stderr. Files, installed packages and background processes persist between calls.',
162
- parameters: execParameters,
323
+ description: 'Run a shell command in the persistent remote workspace (Linux; bash -lc) and return its exit code, stdout and stderr. Files, installed packages and background processes persist between calls. A command that may run longer than a few minutes can run in the background (background: true).',
324
+ parameters: processfulExecParameters,
325
+ // A burst fences the workspace until it applies its changes: the other tools could not run meanwhile.
326
+ refuse: (a) => a.background === true && opts.burst && a.burst === 'always'
327
+ ? ['background cannot be combined with burst always: a burst holds the workspace until it applies its changes; run it without background']
328
+ : [],
163
329
  run: async (a, o) => {
330
+ const burst = opts.burst && (a.burst === 'always' || a.burst === 'never') ? a.burst : undefined;
331
+ const burstVcpus = opts.burst && typeof a.burst_vcpus === 'number' ? a.burst_vcpus : undefined;
332
+ const burstMemoryMib = opts.burst && typeof a.burst_memory_mib === 'number' ? a.burst_memory_mib : undefined;
333
+ const cwd = typeof a.cwd === 'string' ? a.cwd : opts.defaultCwd;
334
+ if (a.background === true) {
335
+ // A session of its own: the start answers at once, and exec_read / exec_cancel find it by session_id.
336
+ // The command runs until it exits or its timeout_ms, if one is set. (Never a burst: refused above.)
337
+ const s = await cell().exec.start({
338
+ argv: ['bash', '-lc', String(a.command)],
339
+ ...(cwd !== undefined ? { cwd } : {}),
340
+ ...(typeof a.timeout_ms === 'number' ? { timeout_ms: a.timeout_ms } : {}),
341
+ ...(typeof a.stdin === 'string' ? { stdin: Buffer.from(a.stdin, 'utf8').toString('base64') } : {}),
342
+ ...(burst !== undefined ? { burst } : {}),
343
+ ...(burstVcpus !== undefined ? { burst_vcpus: burstVcpus } : {}),
344
+ ...(burstMemoryMib !== undefined ? { burst_memory_mib: burstMemoryMib } : {}),
345
+ }, o.signal);
346
+ // Nothing ran (e.g. a cwd that is not a directory): the reason, as the foreground exec gives it.
347
+ const error = s.state === 'failed_to_start' ? sessionError(s) : null;
348
+ return { session_id: s.session_id, state: s.state, ...(error ? { error } : {}) };
349
+ }
164
350
  let r;
165
351
  try {
166
352
  r = await cell().exec.run(['bash', '-lc', String(a.command)], {
167
- ...(typeof a.cwd === 'string' ? { cwd: a.cwd } : opts.defaultCwd ? { cwd: opts.defaultCwd } : {}),
353
+ ...(cwd !== undefined ? { cwd } : {}),
168
354
  timeoutMs: typeof a.timeout_ms === 'number' ? a.timeout_ms : 600_000,
169
355
  ...(typeof a.stdin === 'string' ? { stdin: a.stdin } : {}),
356
+ ...(burst !== undefined ? { burst } : {}),
357
+ ...(burstVcpus !== undefined ? { burstVcpus } : {}),
358
+ ...(burstMemoryMib !== undefined ? { burstMemoryMib } : {}),
170
359
  maxOutputBytes: max,
171
360
  ...(o.signal ? { signal: o.signal } : {}),
172
361
  });
@@ -183,12 +372,89 @@ export function workspaceTools(workspace, opts = {}) {
183
372
  stderr: '',
184
373
  truncated: false,
185
374
  session_id: e.sessionId,
186
- error: { code: e.code, message: e.message, reason: e.reason },
375
+ error: toolError(e),
187
376
  };
188
377
  }
189
- return { exit_code: r.exitCode, term_signal: r.termSignal, timed_out: r.timedOut, stdout: r.stdout, stderr: r.stderr, truncated: r.truncated, session_id: r.sessionId };
378
+ return {
379
+ exit_code: r.exitCode,
380
+ term_signal: r.termSignal,
381
+ timed_out: r.timedOut,
382
+ stdout: r.stdout,
383
+ stderr: r.stderr,
384
+ truncated: r.truncated,
385
+ session_id: r.sessionId,
386
+ // Elastic workspaces (0.13.0): the memory grow the command waited for, when one ran.
387
+ ...(r.memoryGrow ? { memory_grow: r.memoryGrow } : {}),
388
+ // Burst execution (0.13.0, opt-in): what the burst VM ran and applied.
389
+ ...(r.burst ? { burst: r.burst } : {}),
390
+ };
190
391
  },
191
392
  },
393
+ // Background sessions (0.13.0): exec_read and exec_cancel need exec sessions, which a file-first workspace has none of.
394
+ ...(fileFirst
395
+ ? []
396
+ : [
397
+ {
398
+ name: 'exec_read',
399
+ permission: 'exec',
400
+ description: 'Read a command started with exec background: true: its state, exit code and output. Without offsets it returns the end of each stream; pass next_stdout_offset and next_stderr_offset back to read only new output. wait_ms waits up to that long for the command to exit and returns as soon as it does.',
401
+ parameters: obj({ session_id: sessionId, stdout_offset: { type: 'integer', minimum: 0 }, stderr_offset: { type: 'integer', minimum: 0 }, wait_ms: { type: 'integer', minimum: 0, maximum: 60_000 } }, ['session_id']),
402
+ run: async (a, o) => {
403
+ const c = cell();
404
+ const id = String(a.session_id);
405
+ let s = await c.exec.get(id);
406
+ if (typeof a.wait_ms === 'number' && a.wait_ms > 0 && running(s))
407
+ s = await waitForExit(c, s, a.wait_ms, o.signal);
408
+ // Each stream from the offset given, else its last `max` bytes (as they are after the wait).
409
+ const out = new OutputWindow(typeof a.stdout_offset === 'number' ? a.stdout_offset : Math.max(0, s.stdout_size - max), max, s.stdout_size);
410
+ const err = new OutputWindow(typeof a.stderr_offset === 'number' ? a.stderr_offset : Math.max(0, s.stderr_size - max), max, s.stderr_size);
411
+ if (out.start < s.stdout_size || err.start < s.stderr_size) {
412
+ // The guest sends one stream's output up to its end before the other's: when stdout holds more than
413
+ // a window past its offset, stderr is read by a request of its own.
414
+ const ended = err.start < s.stderr_size && s.stdout_size - out.start > max
415
+ ? (await Promise.all([
416
+ drainOutput(c, id, { stdout: out.start, stderr: s.stderr_size }, { stdout: out }, o.signal),
417
+ drainOutput(c, id, { stdout: s.stdout_size, stderr: err.start }, { stderr: err }, o.signal),
418
+ ])).find((x) => x !== null)
419
+ : await drainOutput(c, id, { stdout: out.start, stderr: err.start }, { stdout: out, stderr: err }, o.signal);
420
+ if (ended)
421
+ s = ended;
422
+ }
423
+ const done = !running(s);
424
+ const so = out.result(done);
425
+ const se = err.result(done);
426
+ const error = sessionError(s);
427
+ return {
428
+ session_id: s.session_id,
429
+ state: s.state,
430
+ exit_code: done ? (s.exit_code ?? null) : null,
431
+ term_signal: s.term_signal ?? null,
432
+ timed_out: s.timed_out ?? false,
433
+ canceled: s.canceled ?? false,
434
+ stdout: so.text,
435
+ stderr: se.text,
436
+ stdout_offset: so.offset,
437
+ stderr_offset: se.offset,
438
+ next_stdout_offset: so.next,
439
+ next_stderr_offset: se.next,
440
+ truncated: so.truncated || se.truncated,
441
+ ...(error ? { error } : {}),
442
+ ...(s.burst ? { burst: s.burst } : {}),
443
+ };
444
+ },
445
+ },
446
+ {
447
+ name: 'exec_cancel',
448
+ permission: 'exec',
449
+ description: 'Stop a command started with exec background: true: SIGTERM, then SIGKILL after grace_ms (default 5000).',
450
+ // The workspace waits 5 s without a grace and at most 60 s.
451
+ parameters: obj({ session_id: sessionId, grace_ms: { type: 'integer', minimum: 1, maximum: 60_000 } }, ['session_id']),
452
+ run: async (a) => {
453
+ const s = await cell().exec.cancel(String(a.session_id), typeof a.grace_ms === 'number' ? a.grace_ms : undefined);
454
+ return { session_id: s.session_id, state: s.state, exit_code: running(s) ? null : (s.exit_code ?? null), canceled: s.canceled ?? false };
455
+ },
456
+ },
457
+ ]),
192
458
  {
193
459
  name: 'read_file',
194
460
  permission: 'files',
@@ -434,6 +700,8 @@ export function workspaceTools(workspace, opts = {}) {
434
700
  permission: d.permission,
435
701
  execute: async (args, options = {}) => {
436
702
  const issues = validateArgs(d.parameters, args);
703
+ if (issues.length === 0 && d.refuse)
704
+ issues.push(...d.refuse(args));
437
705
  if (issues.length > 0)
438
706
  throw new ToolArgumentError(`${prefix}${d.name}`, issues);
439
707
  // Fire and forget: a parked workspace starts restoring while this call is prepared. Not for
@@ -2,12 +2,12 @@
2
2
  * A workspace handle: the latest view from the application API plus managed
3
3
  * tool tokens and cell clients (one per agent label / tool set).
4
4
  */
5
- import type { ClientContext, IdlePolicy, DiskLayout, ForkTarget, Operation, SuspendRequest, SuspendWhenIdleOptions, SuspendWhenIdleResult, WaitOptions, WorkspaceLifetime, WorkspaceOrigin, WorkspacePurpose, WorkspaceView } from './client.js';
5
+ import type { AllocationMode, ClientContext, IdlePolicy, DiskLayout, ForkTarget, Operation, SuspendRequest, SuspendWhenIdleOptions, SuspendWhenIdleResult, WaitOptions, WorkspaceLifetime, WorkspaceMemory, WorkspaceOrigin, WorkspacePurpose, WorkspaceView } from './client.js';
6
6
  import { CAPTURE_BARRIER, CellClient } from './cell.js';
7
7
  import { ToolCallCapture } from './capture.js';
8
8
  import type { ToolCallCaptureOptions } from './capture.js';
9
9
  import type { WorkspaceMode } from './errors.js';
10
- import type { FinishedOperation, LifecycleOptions, ResumeOptions, SuspendOptions, WaitedLifecycleOptions, WaitedResumeOptions, WaitedSuspendOptions } from './lifecycle.js';
10
+ import type { FinishedOperation, ForkOptions, LifecycleOptions, ResumeOptions, SuspendOptions, WaitedForkOptions, WaitedLifecycleOptions, WaitedResumeOptions, WaitedSuspendOptions } from './lifecycle.js';
11
11
  import { Trace } from './progress.js';
12
12
  import type { LifecycleTiming, ProgressListener } from './progress.js';
13
13
  import { WorkspaceSecrets } from './secrets.js';
@@ -82,6 +82,13 @@ export declare class Workspace {
82
82
  get grants(): WorkspaceView['grants'];
83
83
  get ceilings(): WorkspaceView['ceilings'];
84
84
  get template(): WorkspaceView['template'];
85
+ /**
86
+ * The template version whose immutable paths the workspace has mounted (0.13.0; `template.immutable_version`): the
87
+ * template's newest published version as of the workspace's last cold boot or resume, which can be newer than
88
+ * `template.version`. Null when the workspace mounts none (its version declares no immutable paths, it has not started
89
+ * since, or an older API).
90
+ */
91
+ get immutableVersion(): number | null;
85
92
  get pendingReason(): string | null;
86
93
  get activeOperation(): WorkspaceView['active_operation'];
87
94
  /** persistent or session; immutable. A view without the field (older API) is persistent. */
@@ -108,6 +115,14 @@ export declare class Workspace {
108
115
  * a resume after the request cancelled it. `refresh()` reads it again.
109
116
  */
110
117
  get suspendRequest(): SuspendRequest | null;
118
+ /**
119
+ * Memory of the workspace as of the last view (0.13.0): `{allocation_mode, promised_mib, held_mib,
120
+ * plugged_mib}`. An elastic workspace holds `held_mib` while idle and is grown towards `promised_mib` when a command
121
+ * needs it; `plugged_mib` is what is plugged above the floor now (null without a live allocation). Null on an older API.
122
+ */
123
+ get memory(): WorkspaceMemory | null;
124
+ /** `fixed` or `elastic` (0.13.0): the running VM's layout, else the next start's. Fixed on an older API. */
125
+ get allocationMode(): AllocationMode;
111
126
  /** The raw view (GET /v1/workspaces/{id}). */
112
127
  get data(): WorkspaceView;
113
128
  /**
@@ -205,11 +220,11 @@ export declare class Workspace {
205
220
  * Forks into a new key. Resolves when the fork is REQUESTED (the copy's handle is returned at once); with
206
221
  * `{ wait: true }`, once the copy exists, with its handle refreshed.
207
222
  */
208
- fork(target: ForkTarget, opts: WaitedLifecycleOptions): Promise<{
223
+ fork(target: ForkTarget, opts: WaitedForkOptions): Promise<{
209
224
  operation: FinishedOperation;
210
225
  workspace: Workspace;
211
226
  }>;
212
- fork(target: ForkTarget, opts?: LifecycleOptions): Promise<{
227
+ fork(target: ForkTarget, opts?: ForkOptions): Promise<{
213
228
  operation: Operation;
214
229
  workspace: Workspace;
215
230
  }>;
package/dist/workspace.js CHANGED
@@ -96,6 +96,15 @@ export class Workspace {
96
96
  get template() {
97
97
  return this.#view.template;
98
98
  }
99
+ /**
100
+ * The template version whose immutable paths the workspace has mounted (0.13.0; `template.immutable_version`): the
101
+ * template's newest published version as of the workspace's last cold boot or resume, which can be newer than
102
+ * `template.version`. Null when the workspace mounts none (its version declares no immutable paths, it has not started
103
+ * since, or an older API).
104
+ */
105
+ get immutableVersion() {
106
+ return this.#view.template.immutable_version ?? null;
107
+ }
99
108
  get pendingReason() {
100
109
  return this.#view.pending_reason;
101
110
  }
@@ -142,6 +151,18 @@ export class Workspace {
142
151
  get suspendRequest() {
143
152
  return this.#view.idle?.suspend_request ?? null;
144
153
  }
154
+ /**
155
+ * Memory of the workspace as of the last view (0.13.0): `{allocation_mode, promised_mib, held_mib,
156
+ * plugged_mib}`. An elastic workspace holds `held_mib` while idle and is grown towards `promised_mib` when a command
157
+ * needs it; `plugged_mib` is what is plugged above the floor now (null without a live allocation). Null on an older API.
158
+ */
159
+ get memory() {
160
+ return this.#view.memory ?? null;
161
+ }
162
+ /** `fixed` or `elastic` (0.13.0): the running VM's layout, else the next start's. Fixed on an older API. */
163
+ get allocationMode() {
164
+ return this.#view.memory?.allocation_mode ?? this.#view.caps?.allocation_mode ?? 'fixed';
165
+ }
145
166
  /** The raw view (GET /v1/workspaces/{id}). */
146
167
  get data() {
147
168
  return this.#view;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/sdk",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Shardflux TypeScript SDK: open persistent agent workspaces by key and give your agent workspace tools (exec, files, processes, PTY, git, browser).",
6
6
  "license": "Apache-2.0",