@shardflux/sdk 0.11.1 → 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/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). */
@@ -89,7 +99,7 @@ async function parseJson(res, what) {
89
99
  return JSON.parse(text);
90
100
  }
91
101
  catch {
92
- throw new ShardfluxProtocolError(`${what}: response is not JSON`, res.status);
102
+ throw new ShardfluxProtocolError(`${what}: response is not JSON`, res.status, 'cell');
93
103
  }
94
104
  }
95
105
  /** Delay before a retry of an execution: Retry-After (at most 30 s), else 0.5 s doubling to 8 s. */
@@ -282,7 +292,7 @@ export class CellClient {
282
292
  return (text.length === 0 ? undefined : JSON.parse(text));
283
293
  }
284
294
  catch {
285
- throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status);
295
+ throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status, 'cell');
286
296
  }
287
297
  }
288
298
  async #bytes(method, path, init = {}) {
@@ -329,20 +339,38 @@ export class CellClient {
329
339
  ...(opts.signal ? { signal: opts.signal } : {}),
330
340
  });
331
341
  if (!res.body)
332
- throw new ShardfluxProtocolError('exec output: empty body', res.status);
342
+ throw new ShardfluxProtocolError('exec output: empty body', res.status, 'cell');
333
343
  return ndjson(res.body);
334
344
  },
345
+ /** Write at the acknowledged offset (initially 0); a repeated identical last frame cannot duplicate input.
346
+ * At most 64 KiB per call. A partial acknowledgement requires continuing from the returned offset.
347
+ * Start with stdin_open: true. close sends EOF after this frame is fully accepted. */
348
+ input: (sessionId, data, opts) => {
349
+ const refusal = this.#noSessions('exec.input');
350
+ if (refusal)
351
+ return Promise.reject(refusal);
352
+ return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/stdin', { session_id: sessionId }), {
353
+ json: { data: b64(data), offset: opts.offset, close: opts.close ?? false }, ...(opts.signal ? { signal: opts.signal } : {}),
354
+ });
355
+ },
335
356
  signal: (sessionId, signal, onlyLeader = false) => {
336
357
  const refusal = this.#noSessions('exec.signal');
337
358
  if (refusal)
338
359
  return Promise.reject(refusal);
339
360
  return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/signal', { session_id: sessionId }), { json: { signal, only_leader: onlyLeader } });
340
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
+ */
341
366
  cancel: (sessionId, graceMs) => {
342
367
  const refusal = this.#noSessions('exec.cancel');
343
368
  if (refusal)
344
369
  return Promise.reject(refusal);
345
- return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/cancel', { session_id: sessionId }), { json: graceMs === undefined ? {} : { grace_ms: graceMs } });
370
+ return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/cancel', { session_id: sessionId }), {
371
+ json: graceMs === undefined ? {} : { grace_ms: graceMs },
372
+ timeoutMs: Math.max(this.#opts.timeoutMs, (graceMs || DEFAULT_CANCEL_GRACE_MS) + 15_000),
373
+ });
346
374
  },
347
375
  /**
348
376
  * Starts (or re-attaches to) a session and collects its output until it exits, reconnecting
@@ -371,21 +399,53 @@ export class CellClient {
371
399
  req.kill_grace_ms = opts.killGraceMs;
372
400
  if (opts.secretRefs !== undefined)
373
401
  req.secret_refs = opts.secretRefs;
374
- const started = await this.exec.start(req, opts.signal);
375
- if (started.state === 'failed_to_start')
376
- throw new ExecStartError(started);
402
+ if (opts.burst !== undefined)
403
+ req.burst = opts.burst;
404
+ if (opts.burstVcpus !== undefined)
405
+ req.burst_vcpus = opts.burstVcpus;
406
+ if (opts.burstMemoryMib !== undefined)
407
+ req.burst_memory_mib = opts.burstMemoryMib;
408
+ const burst = opts.burst === 'always';
409
+ // One request starts the command and follows its output (0.13.0+). Each attempt's response headers are bounded
410
+ // like the old start request; the output stream then runs as long as the command. `initialAbort` ends that
411
+ // stream once it is drained.
412
+ const initialAbort = new AbortController();
413
+ const signal = opts.signal ? AbortSignal.any([opts.signal, initialAbort.signal]) : initialAbort.signal;
414
+ const res = await this.request('POST', this.#p('/v1/workspaces/{workspace_id}/exec'), {
415
+ json: req, accept: 'application/x-ndjson', timeoutMs: 0, headersTimeoutMs: this.#opts.timeoutMs, signal,
416
+ });
417
+ const streamed = res.headers.get('content-type')?.includes('application/x-ndjson') === true;
418
+ let started;
419
+ if (!streamed) {
420
+ // A cell without the combined start, and a start that failed, answer with the session JSON: start, then output.
421
+ const text = await res.text();
422
+ try {
423
+ started = JSON.parse(text);
424
+ }
425
+ catch {
426
+ throw new ShardfluxProtocolError('POST exec: response is not JSON', res.status, 'cell');
427
+ }
428
+ if (started.state === 'failed_to_start')
429
+ throw started.burst?.error ? burstFailure(started.burst.error) : new ExecStartError(started);
430
+ }
377
431
  try {
378
- return await this.#collect(sessionId, opts);
432
+ const r = await this.#collect(sessionId, opts, streamed ? { response: res, abort: initialAbort } : undefined);
433
+ // The start's session carries the grow (a combined stream carries it on its exit event); later reads may not.
434
+ const grow = started?.memory_grow ?? r.session.memory_grow;
435
+ return grow ? { ...r, memoryGrow: grow } : r;
379
436
  }
380
437
  catch (e) {
381
438
  // Aborting the caller must stop the command too, not only our HTTP calls (MCP cancellation, timeouts):
382
- // best-effort cancel (SIGTERM, SIGKILL after the grace), bounded so the abort stays prompt.
383
- if (opts.signal?.aborted && opts.cancelOnAbort !== false) {
439
+ // best-effort cancel (SIGTERM, SIGKILL after the grace), bounded so the abort stays prompt. A burst cannot be
440
+ // canceled (it runs to its end or its timeout).
441
+ if (opts.signal?.aborted && opts.cancelOnAbort !== false && !burst) {
384
442
  let timer;
385
443
  const bound = new Promise((resolve) => {
386
444
  timer = setTimeout(resolve, 5_000);
387
445
  });
388
- await Promise.race([this.exec.cancel(sessionId, opts.killGraceMs).then(() => undefined, () => undefined), bound]);
446
+ // killGraceMs goes up to 600000; a cancel takes at most 60000.
447
+ const grace = opts.killGraceMs === undefined ? undefined : Math.min(opts.killGraceMs, MAX_CANCEL_GRACE_MS);
448
+ await Promise.race([this.exec.cancel(sessionId, grace).then(() => undefined, () => undefined), bound]);
389
449
  clearTimeout(timer);
390
450
  }
391
451
  throw e;
@@ -393,7 +453,7 @@ export class CellClient {
393
453
  },
394
454
  };
395
455
  /** exec.run's output loop: offsets, reconnects, exit. */
396
- async #collect(sessionId, opts) {
456
+ async #collect(sessionId, opts, initial) {
397
457
  let session;
398
458
  const max = opts.maxOutputBytes ?? 1_048_576;
399
459
  const out = new ByteSink(max);
@@ -404,9 +464,18 @@ export class CellClient {
404
464
  const maxReconnects = opts.maxReconnects ?? 10;
405
465
  for (;;) {
406
466
  let exited;
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();
470
+ const signal = opts.signal ? AbortSignal.any([opts.signal, streamAbort.signal]) : streamAbort.signal;
471
+ let drainTimer;
407
472
  try {
408
- const events = await this.exec.output(sessionId, { stdoutOffset: so, stderrOffset: se, follow: true, ...(opts.signal ? { signal: opts.signal } : {}) });
473
+ if (first && !first.response.body)
474
+ throw new ShardfluxProtocolError('exec output: empty body', first.response.status, 'cell');
475
+ const events = first ? ndjson(first.response.body) : await this.exec.output(sessionId, { stdoutOffset: so, stderrOffset: se, follow: true, signal });
409
476
  for await (const ev of events) {
477
+ if (exited)
478
+ continue;
410
479
  if (ev.type === 'output' && ev.data !== undefined) {
411
480
  const bytes = unb64(ev.data);
412
481
  const start = ev.offset ?? (ev.stream === 'stderr' ? se : so);
@@ -426,20 +495,28 @@ export class CellClient {
426
495
  }
427
496
  else if (ev.type === 'exit') {
428
497
  exited = ev.session ?? (await this.exec.get(sessionId));
429
- break;
498
+ // Consume the terminal HTTP framing so the connection can be reused.
499
+ // A peer that never closes after exit must not hold the completed run forever.
500
+ drainTimer = setTimeout(() => streamAbort.abort(), 250);
430
501
  }
431
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 });
432
506
  throw new ShardfluxApiError(502, ev.error, 'cell');
433
507
  }
434
508
  }
435
509
  }
436
510
  catch (e) {
437
- if (opts.signal?.aborted || this.closed)
511
+ if (opts.signal?.aborted || this.closed || (e instanceof ShardfluxApiError && TERMINAL in e))
438
512
  throw e;
439
513
  const transient = !(e instanceof ShardfluxApiError) || e.retryable;
440
- if (!transient || reconnects >= maxReconnects)
514
+ if (!exited && (!transient || reconnects >= maxReconnects))
441
515
  throw e;
442
516
  }
517
+ finally {
518
+ clearTimeout(drainTimer);
519
+ }
443
520
  if (exited) {
444
521
  session = exited;
445
522
  break;
@@ -449,7 +526,7 @@ export class CellClient {
449
526
  throw this.#closer.signal.reason;
450
527
  reconnects += 1;
451
528
  if (reconnects > maxReconnects)
452
- throw new ShardfluxProtocolError(`exec ${sessionId}: output stream kept dropping`, 0);
529
+ throw new ShardfluxProtocolError(`exec ${sessionId}: output stream kept dropping`, 0, 'cell');
453
530
  await this.#opts.sleep(Math.min(2_000, 100 * 2 ** reconnects));
454
531
  const now = await this.exec.get(sessionId);
455
532
  if (now.state !== 'starting' && now.state !== 'running' && so >= now.stdout_size && se >= now.stderr_size) {
@@ -457,6 +534,9 @@ export class CellClient {
457
534
  break;
458
535
  }
459
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);
460
540
  // A start answered while the session was still starting can end without starting the command.
461
541
  if (session.state === 'failed_to_start')
462
542
  throw new ExecStartError(session);
@@ -473,6 +553,8 @@ export class CellClient {
473
553
  truncated: out.total > out.kept || err.total > err.kept,
474
554
  session,
475
555
  reconnects,
556
+ memoryGrow: null,
557
+ burst: session.burst ?? null,
476
558
  };
477
559
  }
478
560
  // ---- executions (file-first workspaces) -----------------------------
@@ -708,7 +790,7 @@ export class CellClient {
708
790
  finish(new ShardfluxApiError(502, m.error, 'cell'));
709
791
  }
710
792
  });
711
- ws.addEventListener('error', () => finish(new ShardfluxProtocolError('pty attach WebSocket failed', 0)));
793
+ ws.addEventListener('error', () => finish(new ShardfluxProtocolError('pty attach WebSocket failed', 0, 'cell')));
712
794
  ws.addEventListener('close', () => finish());
713
795
  if (this.closed)
714
796
  onClose();
@@ -875,6 +957,14 @@ export class CellClient {
875
957
  return Promise.resolve({ residency: 'resident' });
876
958
  return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/wake-hint'), { wake: false, busy: false, timeoutMs: 10_000, ...(signal ? { signal } : {}) });
877
959
  }
960
+ /** Read idle signals without recording activity or waking the workspace. */
961
+ idle(signal) {
962
+ return this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/idle'), { wake: false, ...(signal ? { signal } : {}) });
963
+ }
964
+ /** Declare work for seconds (1..the server maximum). Never shortens a previous keepalive. */
965
+ keepalive(seconds, signal) {
966
+ return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/keepalive'), { json: { seconds }, wake: false, ...(signal ? { signal } : {}) });
967
+ }
878
968
  // ---- changes against the template (layered workspaces) ------------------
879
969
  /**
880
970
  * One page of the workspace's changes against its template (needs the `files` tool). File-first workspaces: each
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, WaitedLifecycleOptions, WaitedResumeOptions } 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'];
@@ -121,7 +119,7 @@ export interface ShardfluxOptions {
121
119
  apiKey: string;
122
120
  /** Default https://api.shardflux.dev (override with `baseUrl`). */
123
121
  baseUrl?: string;
124
- /** Default: the runtime's fetch; on Node 26 every request uses a fresh connection (`Connection: close`) unless SHARDFLUX_HTTP_KEEPALIVE=1. */
122
+ /** Default: pooled HTTP/1.1 on Node 26+, native fetch on other runtimes (see defaultFetch in http.ts). */
125
123
  fetch?: typeof fetch;
126
124
  userAgent?: string;
127
125
  /** Per-request timeout (ms), default 30 s. */
@@ -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;
@@ -172,7 +195,11 @@ export interface WaitOptions {
172
195
  /** Progress while waiting: each observed state (queued, capacity_pending, running with its reason), retries, and `done` with the timing. */
173
196
  onProgress?: ProgressListener;
174
197
  }
198
+ export type IdlePolicy = 'adaptive' | 'never' | `fixed:${number}`;
175
199
  export interface OpenParams {
200
+ /** Searchable metadata; supplied labels replace the existing map. */
201
+ labels?: Record<string, string>;
202
+ idlePolicy?: IdlePolicy;
176
203
  key: string;
177
204
  template: string;
178
205
  caps?: Caps;
@@ -225,6 +252,8 @@ export interface OpenParams {
225
252
  onProgress?: ProgressListener;
226
253
  }
227
254
  export interface ListParams {
255
+ /** All supplied labels must match exactly. */
256
+ labels?: Record<string, string>;
228
257
  state?: WorkspaceView['observed_state'];
229
258
  desiredState?: WorkspaceView['desired_state'];
230
259
  keyPrefix?: string;
@@ -292,6 +321,15 @@ export declare class WorkspacesApi {
292
321
  * (`err.retryable` true: nothing was started; send it again); the SDK does not retry it.
293
322
  */
294
323
  waitForOperation(operationId: string, opts?: WaitOptions): Promise<Operation>;
324
+ /**
325
+ * Waits for the durable copy of a succeeded suspend or fork (0.12.0+): resolves with the operation once
326
+ * `result.durable` is true, typically within a second of the suspend. An operation already durable (or one whose result
327
+ * predates the field) resolves at once without a request. Polls GET /v1/operations/{id} (`pollIntervalMs`, default
328
+ * 250 ms, doubling up to `maxPollIntervalMs`, default 1 000 ms). Throws DurabilityLostError when the copy cannot be
329
+ * made (`durability.state` `lost`), OperationFailedError if the operation did not succeed, and OperationTimeoutError
330
+ * (`durable: true`) after `timeoutMs` (default 300 000 ms; the copy continues server side). `signal` aborts the wait.
331
+ */
332
+ waitForDurable(operation: string | Operation, opts?: WaitOptions): Promise<FinishedOperation>;
295
333
  /** One operation (GET /v1/operations/{id}); lifecycle operations stay pollable after a workspace is deleted. */
296
334
  getOperation(operationId: string, opts?: {
297
335
  signal?: AbortSignal;
@@ -300,6 +338,10 @@ export declare class WorkspacesApi {
300
338
  agentLabel?: string;
301
339
  tools?: ToolName[];
302
340
  }): Promise<Workspace>;
341
+ /** Replace labels. An empty map clears them. */
342
+ setLabels(workspaceId: string, labels: Record<string, string>): Promise<Workspace>;
343
+ /** null clears the override, restoring the template or platform policy. */
344
+ setIdlePolicy(workspaceId: string, idlePolicy: IdlePolicy | null): Promise<Workspace>;
303
345
  list(params?: ListParams): Promise<Page<Workspace>>;
304
346
  /** Iterates every page. */
305
347
  listAll(params?: Omit<ListParams, 'cursor'>): AsyncGenerator<Workspace>;
@@ -317,10 +359,12 @@ export declare class WorkspacesApi {
317
359
  delete(workspaceId: string, opts?: LifecycleOptions): Promise<Operation>;
318
360
  /**
319
361
  * Suspends the workspace (memory and processes checkpointed). Resolves when the suspend is REQUESTED: the returned
320
- * operation is usually still `queued`. Pass `{ wait: true }` to resolve once it has FINISHED (`succeeded`).
362
+ * operation is usually still `queued`. Pass `{ wait: true }` to resolve once it has FINISHED (`succeeded`): the
363
+ * workspace is sealed on its host, typically in a few hundred ms, and `result.durable` turns true when the copy lands
364
+ * in durable storage, typically within a second. `{ durable: true }` (0.12.0+) resolves only then (see SuspendOptions).
321
365
  */
322
- suspend(workspaceId: string, opts: WaitedLifecycleOptions): Promise<FinishedOperation>;
323
- suspend(workspaceId: string, opts?: LifecycleOptions): Promise<Operation>;
366
+ suspend(workspaceId: string, opts: WaitedSuspendOptions): Promise<FinishedOperation>;
367
+ suspend(workspaceId: string, opts?: SuspendOptions): Promise<Operation>;
324
368
  /**
325
369
  * Resumes a suspended workspace. Resolves when the resume is requested; with `wait`, once the workspace runs. With
326
370
  * `wait` (0.9.0) the request is held by the server until the workspace runs (one request, timing phase
@@ -399,13 +443,14 @@ export declare class WorkspacesApi {
399
443
  /**
400
444
  * Forks into a new key. `lifetime` is the fork's own (default persistent): forking a session is how it is kept.
401
445
  * Resolves when the fork is requested (the copy's handle is returned at once); with `wait`, once the copy exists,
402
- * with its handle refreshed.
446
+ * with its handle ready. From 0.13.0, a supporting API returns the target view and final-epoch token together.
447
+ * `agentLabel`/`tools` choose that token; an older API ignoring Prefer falls back to polling and refreshing.
403
448
  */
404
- fork(workspaceId: string, target: ForkTarget, opts: WaitedLifecycleOptions): Promise<{
449
+ fork(workspaceId: string, target: ForkTarget, opts: WaitedForkOptions): Promise<{
405
450
  operation: FinishedOperation;
406
451
  workspace: Workspace;
407
452
  }>;
408
- fork(workspaceId: string, target: ForkTarget, opts?: LifecycleOptions): Promise<{
453
+ fork(workspaceId: string, target: ForkTarget, opts?: ForkOptions): Promise<{
409
454
  operation: Operation;
410
455
  workspace: Workspace;
411
456
  }>;
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
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
4
  import { AuditApi } from "./audit.js";
@@ -9,7 +9,7 @@ import { UsageApi } from "./usage.js";
9
9
  import { VolumesApi } from "./volumes.js";
10
10
  import { versionCheckHook } from "./version-check.js";
11
11
  import { AFTER_WAIT, HELD_RESUME, TRACE, runLifecycle, waitOptionsOf } from "./lifecycle.js";
12
- import { Trace, combineListeners, traced } from "./progress.js";
12
+ import { Trace, combineListeners, durabilityOf, isDurable, traced } from "./progress.js";
13
13
  import { CaptureRegistry } from "./capture.js";
14
14
  import { sendFeedback } from "./feedback.js";
15
15
  /**
@@ -96,6 +96,10 @@ export class WorkspacesApi {
96
96
  body.secrets = params.secrets;
97
97
  if (params.inputs !== undefined)
98
98
  body.inputs = params.inputs;
99
+ if (params.labels !== undefined)
100
+ body.labels = params.labels;
101
+ if (params.idlePolicy !== undefined)
102
+ body.idle_policy = params.idlePolicy;
99
103
  if (params.lifetime !== undefined)
100
104
  body.lifetime = params.lifetime;
101
105
  if (params.mode !== undefined)
@@ -246,6 +250,61 @@ export class WorkspacesApi {
246
250
  interval = Math.min(maxInterval, interval * 2);
247
251
  }
248
252
  }
253
+ /**
254
+ * Waits for the durable copy of a succeeded suspend or fork (0.12.0+): resolves with the operation once
255
+ * `result.durable` is true, typically within a second of the suspend. An operation already durable (or one whose result
256
+ * predates the field) resolves at once without a request. Polls GET /v1/operations/{id} (`pollIntervalMs`, default
257
+ * 250 ms, doubling up to `maxPollIntervalMs`, default 1 000 ms). Throws DurabilityLostError when the copy cannot be
258
+ * made (`durability.state` `lost`), OperationFailedError if the operation did not succeed, and OperationTimeoutError
259
+ * (`durable: true`) after `timeoutMs` (default 300 000 ms; the copy continues server side). `signal` aborts the wait.
260
+ */
261
+ async waitForDurable(operation, opts = {}) {
262
+ const inherited = opts[TRACE];
263
+ const id = typeof operation === 'string' ? operation : operation.id;
264
+ const trace = inherited ?? new Trace('wait', combineListeners(this.#ctx().onProgress, opts.onProgress), { operationId: id });
265
+ const run = async () => {
266
+ const { sleep, http } = this.#ctx();
267
+ const timeoutMs = opts.timeoutMs ?? 300_000;
268
+ const maxInterval = opts.maxPollIntervalMs ?? 1_000;
269
+ let interval = opts.pollIntervalMs ?? 250;
270
+ const started = Date.now();
271
+ const aborted = () => (opts.signal?.reason instanceof Error ? opts.signal.reason : new Error('aborted'));
272
+ let op = typeof operation === 'string' ? null : operation;
273
+ for (;;) {
274
+ if (opts.signal?.aborted)
275
+ throw aborted();
276
+ if (op === null) {
277
+ const { body } = await pollWithWait(http, `/v1/operations/${encodeURIComponent(id)}`, this.#ctx().authorization, 0, opts.signal, trace.onRetry);
278
+ op = body.operation;
279
+ trace.observe(op);
280
+ }
281
+ if (op.state !== 'succeeded') {
282
+ if (TERMINAL.has(op.state))
283
+ throw new OperationFailedError(op);
284
+ // Not finished yet (a fork or suspend passed by id): wait for it first, then for its copy.
285
+ op = await this.waitForOperation(id, { ...opts, timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)), [TRACE]: trace });
286
+ }
287
+ if (isDurable(op) !== false)
288
+ return op;
289
+ const durability = durabilityOf(op);
290
+ if (durability?.state === 'lost')
291
+ throw new DurabilityLostError(op);
292
+ trace.phase('durable', durability?.overdueAt ? 'overdue' : null);
293
+ const waited = Date.now() - started;
294
+ if (waited >= timeoutMs)
295
+ throw new OperationTimeoutError(op, waited, true);
296
+ const jitter = interval * 0.2 * (Math.random() * 2 - 1);
297
+ const delay = Math.max(10, Math.min(interval + jitter, timeoutMs - waited));
298
+ await (opts.signal ? abortableSleep(sleep, delay, opts.signal) : sleep(delay));
299
+ interval = Math.min(maxInterval, interval * 2);
300
+ op = null;
301
+ }
302
+ };
303
+ if (inherited)
304
+ return run();
305
+ trace.phase('request');
306
+ return traced(trace, run);
307
+ }
249
308
  /** One operation (GET /v1/operations/{id}); lifecycle operations stay pollable after a workspace is deleted. */
250
309
  async getOperation(operationId, opts = {}) {
251
310
  const { operation } = await this.#http.json('GET', `/v1/operations/${encodeURIComponent(operationId)}`, opts.signal ? { signal: opts.signal } : {}, this.#auth);
@@ -257,12 +316,21 @@ export class WorkspacesApi {
257
316
  async get(workspaceId, opts = {}) {
258
317
  return this.#wrap(await this.#getView(workspaceId), opts);
259
318
  }
319
+ /** Replace labels. An empty map clears them. */
320
+ async setLabels(workspaceId, labels) {
321
+ return this.#wrap(await this.#http.json('PUT', `/v1/workspaces/${encodeURIComponent(workspaceId)}/labels`, { json: { labels } }, this.#auth));
322
+ }
323
+ /** null clears the override, restoring the template or platform policy. */
324
+ async setIdlePolicy(workspaceId, idlePolicy) {
325
+ return this.#wrap(await this.#http.json('PUT', `/v1/workspaces/${encodeURIComponent(workspaceId)}/idle-policy`, { json: { idle_policy: idlePolicy } }, this.#auth));
326
+ }
260
327
  async list(params = {}) {
261
328
  const page = await this.#http.json('GET', '/v1/workspaces', {
262
329
  query: {
263
330
  state: params.state,
264
331
  desired_state: params.desiredState,
265
332
  key_prefix: params.keyPrefix,
333
+ labels: params.labels === undefined ? undefined : JSON.stringify(params.labels),
266
334
  project_id: params.projectId,
267
335
  organization_id: params.organizationId,
268
336
  include_deleted: params.includeDeleted,
@@ -406,11 +474,11 @@ export class WorkspacesApi {
406
474
  const res = await this.#http.jsonWithHeaders('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/resume`, init, this.#auth);
407
475
  const b = res.body;
408
476
  if (!b || typeof b !== 'object' || !b.workspace)
409
- throw new ShardfluxProtocolError('resume: response has no workspace', res.status);
477
+ throw new ShardfluxProtocolError('resume: response has no workspace', res.status, 'api');
410
478
  const pa = res.headers.get('preference-applied');
411
479
  const ready = held && res.status === 200 && pa !== null && /\bwait\s*=/i.test(pa);
412
480
  if (!ready && !b.operation)
413
- throw new ShardfluxProtocolError('resume: response has no operation', res.status);
481
+ throw new ShardfluxProtocolError('resume: response has no operation', res.status, 'api');
414
482
  const toolToken = ready ? (b.tool_token ?? null) : null;
415
483
  this.#noteToken(toolToken);
416
484
  return { ready, operation: b.operation ?? null, workspace: b.workspace, toolToken, requestId: res.headers.get('x-request-id') };
@@ -475,17 +543,45 @@ export class WorkspacesApi {
475
543
  }
476
544
  async fork(workspaceId, target, opts = {}) {
477
545
  let copy;
478
- const operation = await runLifecycle(this.#ctx(), 'fork', workspaceId, async (init) => {
479
- const out = await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/fork`, target, opts.idempotencyKey, init);
480
- copy = this.#wrap(out.workspace);
546
+ let heldReady = false;
547
+ const requestedWait = waitOptionsOf(opts);
548
+ const wait = requestedWait ? { ...requestedWait } : null;
549
+ const timeoutMs = wait?.timeoutMs ?? 300_000;
550
+ const started = Date.now();
551
+ const operation = await runLifecycle(this.#ctx(), 'fork', workspaceId, async (init, trace) => {
552
+ const waitS = wait && wait.serverWait !== false ? Math.min(SERVER_WAIT_MAX_S, Math.floor(timeoutMs / 1000)) : 0;
553
+ const body = { ...target };
554
+ if (waitS >= 1 && opts.agentLabel !== undefined)
555
+ body.agent_label = opts.agentLabel;
556
+ if (waitS >= 1 && opts.tools !== undefined)
557
+ body.tools = [...opts.tools];
558
+ const res = await this.#http.jsonWithHeaders('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/fork`, {
559
+ ...init, json: body, idempotencyKey: opts.idempotencyKey ?? randomId('op-'),
560
+ ...(wait?.signal ? { signal: wait.signal } : {}),
561
+ ...(waitS >= 1 ? { headers: { prefer: `wait=${waitS}` }, timeoutMs: Math.max(this.#http.opts.timeoutMs, waitS * 1000 + 10_000) } : {}),
562
+ }, this.#auth);
563
+ const out = res.body;
564
+ if (!out?.workspace || !out.operation)
565
+ throw new ShardfluxProtocolError('fork: response has no workspace or operation', res.status, 'api');
566
+ heldReady = waitS >= 1 && res.status === 200 && /\bwait\s*=/i.test(res.headers.get('preference-applied') ?? '');
567
+ if (heldReady && (out.operation.state !== 'succeeded' || out.workspace.observed_state !== 'running')) {
568
+ throw new ShardfluxProtocolError('fork: held response is not a succeeded running target', res.status, 'api');
569
+ }
570
+ const token = heldReady ? out.tool_token ?? null : null;
571
+ this.#noteToken(token);
572
+ copy = this.#wrap(out.workspace, { agentLabel: opts.agentLabel, tools: opts.tools, token, trace });
573
+ if (wait && waitS >= 1)
574
+ wait.timeoutMs = Math.max(1, timeoutMs - (Date.now() - started));
481
575
  return out.operation;
482
576
  }, {
483
577
  ...opts,
578
+ ...(wait ? { wait } : {}),
484
579
  [AFTER_WAIT]: async (trace, op) => {
485
- await trace.span('view', () => copy.refresh());
580
+ if (!heldReady)
581
+ await trace.span('view', () => copy.refresh());
486
582
  await opts[AFTER_WAIT]?.(trace, op);
487
583
  },
488
- }, { settle: true });
584
+ }, { settle: true, requestReason: wait && wait.serverWait !== false && timeoutMs >= 1000 ? 'held' : null });
489
585
  return { operation, workspace: copy };
490
586
  }
491
587
  /** The workspace's text inputs `{NAME: value}` (0.7.0). Secret inputs are bound secrets, never listed. */