@shardflux/sdk 0.12.0 → 0.13.1

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
@@ -1,7 +1,10 @@
1
- import { ExecStartError, NotSupportedForModeError, ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
1
+ import { ExecStartError, NotSupportedForModeError, ShardfluxApiError, ShardfluxProtocolError, apiError, isErrorBody, isWorkingQuotaRefusal } from "./errors.js";
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) => {
@@ -17,8 +20,20 @@ export const DEFAULT_TRANSITION_TIMEOUT_MS = 120_000;
17
20
  * capture writes recorded before them (read-your-writes). Workspace.cell() sets it; the capture's own client does not.
18
21
  */
19
22
  export const CAPTURE_BARRIER = Symbol('shardflux.captureBarrier');
20
- /** Wakes per call at most: a workspace that keeps being suspended again surfaces the refusal. */
23
+ /**
24
+ * Wakes per call at most: a workspace that keeps being suspended again, or keeps refusing the call while the API reports
25
+ * it running, surfaces the refusal.
26
+ */
21
27
  const MAX_WAKES = 3;
28
+ /** The pause before retrying a call refused again while the API reports the workspace running (from the second time). */
29
+ const RUNNING_AGAIN_PAUSE_MS = 500;
30
+ /** exec.cancel grace: the workspace waits 5 s without one and takes at most 60 s. */
31
+ const DEFAULT_CANCEL_GRACE_MS = 5_000;
32
+ const MAX_CANCEL_GRACE_MS = 60_000;
33
+ /** The API error of a burst's recorded failure (`burst.error` of its session). Internal (the agent tools use it too). */
34
+ export function burstFailure(e) {
35
+ return new ShardfluxApiError(409, { error: { code: e.code, message: e.message, request_id: '', retryable: e.retryable, ...(e.details ? { details: e.details } : {}) } }, 'cell');
36
+ }
22
37
  const b64 = (bytes) => Buffer.from(typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : bytes).toString('base64');
23
38
  const unb64 = (s) => (s ? new Uint8Array(Buffer.from(s, 'base64')) : new Uint8Array());
24
39
  /** Parses an NDJSON byte stream into objects (tolerates CRLF and a final unterminated line). */
@@ -53,6 +68,30 @@ export async function* ndjson(body) {
53
68
  reader.releaseLock();
54
69
  }
55
70
  }
71
+ /**
72
+ * A refused attach the gateway had to accept (cell-api.yaml "WebSocket close codes": an allowed Origin cannot read the
73
+ * HTTP answer to a failed upgrade): its only text frame is exactly the ErrorBody, then a close with 4000 + the HTTP
74
+ * status (4410 for not_found). A 4429 without a body (its close reason is `{"code","retryable",...}`) is the same
75
+ * refusal in short. Undefined for any other close.
76
+ */
77
+ function attachRefusal(code, reason, body) {
78
+ const status = code === 4410 ? 404 : code !== undefined && code >= 4000 && code < 5000 ? code - 4000 : undefined;
79
+ if (body) {
80
+ const hinted = body.error.details?.retry_after_seconds;
81
+ return apiError(status ?? 502, body, 'cell', typeof hinted === 'number' && hinted >= 0 ? hinted : undefined);
82
+ }
83
+ if (code !== 4429)
84
+ return undefined;
85
+ let short = {};
86
+ try {
87
+ short = JSON.parse(reason ?? '');
88
+ }
89
+ catch {
90
+ // Not the compact JSON: the close code alone says it.
91
+ }
92
+ const errCode = typeof short.code === 'string' ? short.code : 'rate_limited';
93
+ return apiError(429, { error: { code: errCode, message: `The attach was refused (${errCode}); retry shortly.`, request_id: typeof short.request_id === 'string' ? short.request_id : '', retryable: short.retryable !== false } }, 'cell');
94
+ }
56
95
  class ByteSink {
57
96
  #max;
58
97
  #chunks = [];
@@ -98,6 +137,10 @@ const executionRetryDelayMs = (failures, retryAfterSeconds) => retryAfterSeconds
98
137
  const attemptTimedOut = (err) => err instanceof Error && err.name === 'TimeoutError';
99
138
  /** A transport failure or a retryable 429/5xx: the execution request may be sent again with the same id. */
100
139
  function transientExecutionFailure(err) {
140
+ // The working-at-once refusal was already retried by the HTTP layer within the client's maxRetries: a second loop
141
+ // here would multiply that budget, so it surfaces.
142
+ if (isWorkingQuotaRefusal(err))
143
+ return null;
101
144
  if (err instanceof ShardfluxApiError) {
102
145
  const status = err.status === 429 || err.status === 502 || err.status === 503 || err.status === 504;
103
146
  return status && err.retryable ? { retryAfterSeconds: err.retryAfterSeconds } : null;
@@ -203,8 +246,10 @@ export class CellClient {
203
246
  * One authorized request. Refreshes the token once on stale_epoch / 401. Lifecycle transitions,
204
247
  * bounded in total by `transitionTimeoutMs`: a call refused with `workspace_busy` is retried after `Retry-After`; one
205
248
  * refused with `workspace_not_running` (or whose token cannot be minted because the workspace is not running) wakes
206
- * the workspace through `wake`, given the time left, and is retried with a fresh token, at most 3 wakes per call.
207
- * Refused calls were never executed, so retrying is safe. When the budget is spent the refusal surfaces.
249
+ * the workspace through `wake`, given the time left, and is retried with a fresh token, at most 3 wakes per call. A
250
+ * wake that finds the workspace already running (`false`) is retried the same way: the transition that refused the
251
+ * call ended meanwhile (0.13.1+). Refused calls were never executed, so retrying is safe. When the budget is spent
252
+ * the refusal surfaces.
208
253
  */
209
254
  async request(method, path, init = {}) {
210
255
  const closer = this.#closer.signal;
@@ -227,6 +272,7 @@ export class CellClient {
227
272
  const retry = (cause, delayMs, attempt) => emit?.({ type: 'retry', retry: { atMs: Math.round((performance.now() - t0) * 10) / 10, request: `${method} ${path}`, attempt, cause, delayMs } });
228
273
  let refreshed = false;
229
274
  let wakes = 0;
275
+ let runningAgain = 0;
230
276
  let attempts = 0;
231
277
  for (;;) {
232
278
  try {
@@ -260,8 +306,17 @@ export class CellClient {
260
306
  if (notRunning && wakeAllowed && this.#wake && wakes < MAX_WAKES && left > 0) {
261
307
  wakes += 1;
262
308
  const before = this.tokens.current;
263
- if ((await this.#wake(left, signal)) === false)
264
- throw err; // running per the API: nothing to wait for
309
+ if ((await this.#wake(left, signal)) === false) {
310
+ // Running per the API: the refusal was answered from a transition that ended before the wake looked (a
311
+ // resume or a move committed in between: the token request was refused while the workspace was resuming,
312
+ // and the wake found it running). The refused call never ran, so it is retried with a current token. A
313
+ // refusal that comes back while the API keeps reporting the workspace running is retried after a pause,
314
+ // within the same wakes and time budget, then surfaces.
315
+ const pause = runningAgain++ === 0 ? 0 : Math.max(0, Math.min(deadline - Date.now(), RUNNING_AGAIN_PAUSE_MS));
316
+ retry(`${err.code === 'conflict' ? 'conflict workspace_not_running' : err.code} (the workspace runs; new token)`, pause, (attempts += 1));
317
+ if (pause > 0)
318
+ await this.#opts.sleep(pause);
319
+ }
265
320
  // A held resume handed this client a token of the woken workspace: use it. Otherwise the
266
321
  // old token is of the previous epoch: fetch a new one.
267
322
  if (this.tokens.current === before)
@@ -349,11 +404,18 @@ export class CellClient {
349
404
  return Promise.reject(refusal);
350
405
  return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/signal', { session_id: sessionId }), { json: { signal, only_leader: onlyLeader } });
351
406
  },
407
+ /**
408
+ * SIGTERM to the process group, SIGKILL after `graceMs` (0 to 60000; 0 or omitted is 5000). Resolves once the
409
+ * command has ended; the request waits for the grace.
410
+ */
352
411
  cancel: (sessionId, graceMs) => {
353
412
  const refusal = this.#noSessions('exec.cancel');
354
413
  if (refusal)
355
414
  return Promise.reject(refusal);
356
- return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/cancel', { session_id: sessionId }), { json: graceMs === undefined ? {} : { grace_ms: graceMs } });
415
+ return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/cancel', { session_id: sessionId }), {
416
+ json: graceMs === undefined ? {} : { grace_ms: graceMs },
417
+ timeoutMs: Math.max(this.#opts.timeoutMs, (graceMs || DEFAULT_CANCEL_GRACE_MS) + 15_000),
418
+ });
357
419
  },
358
420
  /**
359
421
  * Starts (or re-attaches to) a session and collects its output until it exits, reconnecting
@@ -382,21 +444,53 @@ export class CellClient {
382
444
  req.kill_grace_ms = opts.killGraceMs;
383
445
  if (opts.secretRefs !== undefined)
384
446
  req.secret_refs = opts.secretRefs;
385
- const started = await this.exec.start(req, opts.signal);
386
- if (started.state === 'failed_to_start')
387
- throw new ExecStartError(started);
447
+ if (opts.burst !== undefined)
448
+ req.burst = opts.burst;
449
+ if (opts.burstVcpus !== undefined)
450
+ req.burst_vcpus = opts.burstVcpus;
451
+ if (opts.burstMemoryMib !== undefined)
452
+ req.burst_memory_mib = opts.burstMemoryMib;
453
+ const burst = opts.burst === 'always';
454
+ // One request starts the command and follows its output (0.13.0+). Each attempt's response headers are bounded
455
+ // like the old start request; the output stream then runs as long as the command. `initialAbort` ends that
456
+ // stream once it is drained.
457
+ const initialAbort = new AbortController();
458
+ const signal = opts.signal ? AbortSignal.any([opts.signal, initialAbort.signal]) : initialAbort.signal;
459
+ const res = await this.request('POST', this.#p('/v1/workspaces/{workspace_id}/exec'), {
460
+ json: req, accept: 'application/x-ndjson', timeoutMs: 0, headersTimeoutMs: this.#opts.timeoutMs, signal,
461
+ });
462
+ const streamed = res.headers.get('content-type')?.includes('application/x-ndjson') === true;
463
+ let started;
464
+ if (!streamed) {
465
+ // A cell without the combined start, and a start that failed, answer with the session JSON: start, then output.
466
+ const text = await res.text();
467
+ try {
468
+ started = JSON.parse(text);
469
+ }
470
+ catch {
471
+ throw new ShardfluxProtocolError('POST exec: response is not JSON', res.status, 'cell');
472
+ }
473
+ if (started.state === 'failed_to_start')
474
+ throw started.burst?.error ? burstFailure(started.burst.error) : new ExecStartError(started);
475
+ }
388
476
  try {
389
- return await this.#collect(sessionId, opts);
477
+ const r = await this.#collect(sessionId, opts, streamed ? { response: res, abort: initialAbort } : undefined);
478
+ // The start's session carries the grow (a combined stream carries it on its exit event); later reads may not.
479
+ const grow = started?.memory_grow ?? r.session.memory_grow;
480
+ return grow ? { ...r, memoryGrow: grow } : r;
390
481
  }
391
482
  catch (e) {
392
483
  // Aborting the caller must stop the command too, not only our HTTP calls (MCP cancellation, timeouts):
393
- // best-effort cancel (SIGTERM, SIGKILL after the grace), bounded so the abort stays prompt.
394
- if (opts.signal?.aborted && opts.cancelOnAbort !== false) {
484
+ // best-effort cancel (SIGTERM, SIGKILL after the grace), bounded so the abort stays prompt. A burst cannot be
485
+ // canceled (it runs to its end or its timeout).
486
+ if (opts.signal?.aborted && opts.cancelOnAbort !== false && !burst) {
395
487
  let timer;
396
488
  const bound = new Promise((resolve) => {
397
489
  timer = setTimeout(resolve, 5_000);
398
490
  });
399
- await Promise.race([this.exec.cancel(sessionId, opts.killGraceMs).then(() => undefined, () => undefined), bound]);
491
+ // killGraceMs goes up to 600000; a cancel takes at most 60000.
492
+ const grace = opts.killGraceMs === undefined ? undefined : Math.min(opts.killGraceMs, MAX_CANCEL_GRACE_MS);
493
+ await Promise.race([this.exec.cancel(sessionId, grace).then(() => undefined, () => undefined), bound]);
400
494
  clearTimeout(timer);
401
495
  }
402
496
  throw e;
@@ -404,7 +498,7 @@ export class CellClient {
404
498
  },
405
499
  };
406
500
  /** exec.run's output loop: offsets, reconnects, exit. */
407
- async #collect(sessionId, opts) {
501
+ async #collect(sessionId, opts, initial) {
408
502
  let session;
409
503
  const max = opts.maxOutputBytes ?? 1_048_576;
410
504
  const out = new ByteSink(max);
@@ -415,11 +509,15 @@ export class CellClient {
415
509
  const maxReconnects = opts.maxReconnects ?? 10;
416
510
  for (;;) {
417
511
  let exited;
418
- const streamAbort = new AbortController();
512
+ const first = initial;
513
+ initial = undefined; // A dropped combined response reconnects only by GET, never re-starting the command.
514
+ const streamAbort = first?.abort ?? new AbortController();
419
515
  const signal = opts.signal ? AbortSignal.any([opts.signal, streamAbort.signal]) : streamAbort.signal;
420
516
  let drainTimer;
421
517
  try {
422
- const events = await this.exec.output(sessionId, { stdoutOffset: so, stderrOffset: se, follow: true, signal });
518
+ if (first && !first.response.body)
519
+ throw new ShardfluxProtocolError('exec output: empty body', first.response.status, 'cell');
520
+ const events = first ? ndjson(first.response.body) : await this.exec.output(sessionId, { stdoutOffset: so, stderrOffset: se, follow: true, signal });
423
521
  for await (const ev of events) {
424
522
  if (exited)
425
523
  continue;
@@ -447,12 +545,15 @@ export class CellClient {
447
545
  drainTimer = setTimeout(() => streamAbort.abort(), 250);
448
546
  }
449
547
  else if (ev.type === 'error' && ev.error) {
548
+ // A burst's failure is its terminal outcome, not a dropped stream: never reconnect.
549
+ if (BURST_FAILURES.has(ev.error.error.code))
550
+ throw Object.assign(new ShardfluxApiError(409, ev.error, 'cell'), { [TERMINAL]: true });
450
551
  throw new ShardfluxApiError(502, ev.error, 'cell');
451
552
  }
452
553
  }
453
554
  }
454
555
  catch (e) {
455
- if (opts.signal?.aborted || this.closed)
556
+ if (opts.signal?.aborted || this.closed || (e instanceof ShardfluxApiError && TERMINAL in e))
456
557
  throw e;
457
558
  const transient = !(e instanceof ShardfluxApiError) || e.retryable;
458
559
  if (!exited && (!transient || reconnects >= maxReconnects))
@@ -478,6 +579,9 @@ export class CellClient {
478
579
  break;
479
580
  }
480
581
  }
582
+ // A burst that failed after its start answered (the session ended with burst.error).
583
+ if (session.burst?.error)
584
+ throw burstFailure(session.burst.error);
481
585
  // A start answered while the session was still starting can end without starting the command.
482
586
  if (session.state === 'failed_to_start')
483
587
  throw new ExecStartError(session);
@@ -494,6 +598,8 @@ export class CellClient {
494
598
  truncated: out.total > out.kept || err.total > err.kept,
495
599
  session,
496
600
  reconnects,
601
+ memoryGrow: null,
602
+ burst: session.burst ?? null,
497
603
  };
498
604
  }
499
605
  // ---- executions (file-first workspaces) -----------------------------
@@ -683,6 +789,7 @@ export class CellClient {
683
789
  let next = opts.offset ?? 0;
684
790
  let session = null;
685
791
  let exited = false;
792
+ let refusal;
686
793
  await new Promise((resolve, reject) => {
687
794
  const onClose = () => finish(this.#closer.signal.reason instanceof Error ? this.#closer.signal.reason : new Error('closed'));
688
795
  const ws = new WS(url, { headers: { authorization: `Bearer ${token}` } });
@@ -699,8 +806,10 @@ export class CellClient {
699
806
  catch {
700
807
  // already closed
701
808
  }
702
- if (err)
703
- reject(err);
809
+ // A refusal body whose close did not arrive within the read: the body alone says it.
810
+ const failure = err ?? (refusal ? attachRefusal(undefined, undefined, refusal) : undefined);
811
+ if (failure)
812
+ reject(failure);
704
813
  else
705
814
  resolve();
706
815
  };
@@ -714,6 +823,12 @@ export class CellClient {
714
823
  if (typeof e.data !== 'string')
715
824
  return;
716
825
  const m = JSON.parse(e.data);
826
+ // A refused attach (e.g. 4429 quota_exceeded: every working slot is taken): the bare ErrorBody, then the close.
827
+ if (!('type' in m)) {
828
+ if (isErrorBody(m))
829
+ refusal = m;
830
+ return;
831
+ }
717
832
  if (m.type === 'output' && m.data) {
718
833
  const bytes = unb64(m.data);
719
834
  sink.push(bytes);
@@ -730,7 +845,7 @@ export class CellClient {
730
845
  }
731
846
  });
732
847
  ws.addEventListener('error', () => finish(new ShardfluxProtocolError('pty attach WebSocket failed', 0, 'cell')));
733
- ws.addEventListener('close', () => finish());
848
+ ws.addEventListener('close', (e) => finish(attachRefusal(e.code, e.reason, refusal)));
734
849
  if (this.closed)
735
850
  onClose();
736
851
  else
@@ -894,7 +1009,8 @@ export class CellClient {
894
1009
  // Nothing of a file-first workspace sleeps: the cell would answer `resident`.
895
1010
  if (this.mode === 'file_first')
896
1011
  return Promise.resolve({ residency: 'resident' });
897
- return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/wake-hint'), { wake: false, busy: false, timeoutMs: 10_000, ...(signal ? { signal } : {}) });
1012
+ // A hint never waits: a 429 quota_exceeded (every working slot is taken) surfaces at once, like its other refusals.
1013
+ return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/wake-hint'), { wake: false, busy: false, quotaRetry: false, timeoutMs: 10_000, ...(signal ? { signal } : {}) });
898
1014
  }
899
1015
  /** Read idle signals without recording activity or waking the workspace. */
900
1016
  idle(signal) {
package/dist/client.d.ts CHANGED
@@ -22,7 +22,7 @@ import type { SaveAsTemplateParams, SaveAsTemplateResponse } from './templates.j
22
22
  import { UsageApi } from './usage.js';
23
23
  import { VolumesApi } from './volumes.js';
24
24
  import type { VersionCheckOption } from './version-check.js';
25
- import type { FinishedOperation, InternalLifecycleOptions, LifecycleOptions, ResumeOptions, SuspendOptions, WaitedLifecycleOptions, WaitedResumeOptions, WaitedSuspendOptions } from './lifecycle.js';
25
+ import type { FinishedOperation, ForkOptions, InternalLifecycleOptions, LifecycleOptions, ResumeOptions, SuspendOptions, WaitedForkOptions, WaitedLifecycleOptions, WaitedResumeOptions, WaitedSuspendOptions } from './lifecycle.js';
26
26
  import type { ProgressListener } from './progress.js';
27
27
  import { CaptureRegistry } from './capture.js';
28
28
  import type { FeedbackReceipt, SendFeedbackParams } from './feedback.js';
@@ -34,8 +34,6 @@ export type WorkspaceLifetime = components['schemas']['WorkspaceLifetime'];
34
34
  export type DiskLayout = components['schemas']['DiskLayout'];
35
35
  /** standard, template_draft (a template's dev-mode draft) or template_test (a test instance of a draft state). */
36
36
  export type WorkspacePurpose = components['schemas']['WorkspacePurpose'];
37
- /** Reserved (T2): always `pinned` in T1. */
38
- export type UpdatePolicy = components['schemas']['UpdatePolicy'];
39
37
  /** Where the workspace's disk came from: null, a fork, or a draft state (test instances). */
40
38
  export type WorkspaceOrigin = components['schemas']['WorkspaceOrigin'];
41
39
  export type ResetWorkspaceBody = components['schemas']['ResetWorkspaceBody'];
@@ -143,10 +141,35 @@ export interface ShardfluxOptions {
143
141
  */
144
142
  versionCheck?: VersionCheckOption;
145
143
  }
144
+ /**
145
+ * How a workspace holds its memory (0.13.0): `fixed` boots `memory_mib` and holds it; `elastic` makes
146
+ * `memory_mib` a promise: the VM holds `memory_mib_held` while idle and the host grows it when a command needs it.
147
+ */
148
+ export type AllocationMode = 'fixed' | 'elastic';
149
+ /**
150
+ * Memory of a workspace (0.13.0): `promised_mib` (what it may grow to), `held_mib` (the idle floor;
151
+ * for a fixed workspace the same as the promise) and `plugged_mib` (plugged above the held floor on the live
152
+ * allocation, refreshed about every 60 s; null without one). `allocation_mode` is the running VM's, else the next start's.
153
+ */
154
+ export type WorkspaceMemory = WorkspaceView['memory'];
146
155
  export interface Caps {
147
156
  cpu_millis?: number;
157
+ /** Memory in MiB; for an elastic workspace the promise (what it may grow to). */
148
158
  memory_mib?: number;
149
159
  disk_gib?: number;
160
+ /**
161
+ * `fixed` (default) or `elastic` (0.13.0). Given caps replace the stored ones: caps without it make
162
+ * the workspace fixed again; omitted caps keep the stored layout. Elastic needs the organization's entitlement, else
163
+ * ShardfluxApiError 422 `validation_failed` reason `allocation_mode_not_available` (nothing is created or changed);
164
+ * a file-first workspace gets `not_supported_for_mode`. A promise that does not exceed the held floor by at least
165
+ * 512 MiB is fixed at the promise (the returned `caps.allocation_mode` says which). Takes effect at the next VM start.
166
+ */
167
+ allocation_mode?: AllocationMode;
168
+ /**
169
+ * Elastic only (0.13.0): the memory held while idle, in MiB (at least 512, default 1024 server side, at most
170
+ * `memory_mib`). Without elastic: 422 reason `requires_elastic`; above `memory_mib`: 422 `exceeds_memory_mib`.
171
+ */
172
+ memory_mib_held?: number;
150
173
  }
151
174
  export interface ForkTarget {
152
175
  key: string;
@@ -420,13 +443,14 @@ export declare class WorkspacesApi {
420
443
  /**
421
444
  * Forks into a new key. `lifetime` is the fork's own (default persistent): forking a session is how it is kept.
422
445
  * Resolves when the fork is requested (the copy's handle is returned at once); with `wait`, once the copy exists,
423
- * with its handle refreshed.
446
+ * with its handle ready. From 0.13.0, a supporting API returns the target view and final-epoch token together.
447
+ * `agentLabel`/`tools` choose that token; an older API ignoring Prefer falls back to polling and refreshing.
424
448
  */
425
- fork(workspaceId: string, target: ForkTarget, opts: WaitedLifecycleOptions): Promise<{
449
+ fork(workspaceId: string, target: ForkTarget, opts: WaitedForkOptions): Promise<{
426
450
  operation: FinishedOperation;
427
451
  workspace: Workspace;
428
452
  }>;
429
- fork(workspaceId: string, target: ForkTarget, opts?: LifecycleOptions): Promise<{
453
+ fork(workspaceId: string, target: ForkTarget, opts?: ForkOptions): Promise<{
430
454
  operation: Operation;
431
455
  workspace: Workspace;
432
456
  }>;
package/dist/client.js CHANGED
@@ -543,17 +543,45 @@ export class WorkspacesApi {
543
543
  }
544
544
  async fork(workspaceId, target, opts = {}) {
545
545
  let copy;
546
- const operation = await runLifecycle(this.#ctx(), 'fork', workspaceId, async (init) => {
547
- const out = await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/fork`, target, opts.idempotencyKey, init);
548
- copy = this.#wrap(out.workspace);
546
+ let heldReady = false;
547
+ const requestedWait = waitOptionsOf(opts);
548
+ const wait = requestedWait ? { ...requestedWait } : null;
549
+ const timeoutMs = wait?.timeoutMs ?? 300_000;
550
+ const started = Date.now();
551
+ const operation = await runLifecycle(this.#ctx(), 'fork', workspaceId, async (init, trace) => {
552
+ const waitS = wait && wait.serverWait !== false ? Math.min(SERVER_WAIT_MAX_S, Math.floor(timeoutMs / 1000)) : 0;
553
+ const body = { ...target };
554
+ if (waitS >= 1 && opts.agentLabel !== undefined)
555
+ body.agent_label = opts.agentLabel;
556
+ if (waitS >= 1 && opts.tools !== undefined)
557
+ body.tools = [...opts.tools];
558
+ const res = await this.#http.jsonWithHeaders('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/fork`, {
559
+ ...init, json: body, idempotencyKey: opts.idempotencyKey ?? randomId('op-'),
560
+ ...(wait?.signal ? { signal: wait.signal } : {}),
561
+ ...(waitS >= 1 ? { headers: { prefer: `wait=${waitS}` }, timeoutMs: Math.max(this.#http.opts.timeoutMs, waitS * 1000 + 10_000) } : {}),
562
+ }, this.#auth);
563
+ const out = res.body;
564
+ if (!out?.workspace || !out.operation)
565
+ throw new ShardfluxProtocolError('fork: response has no workspace or operation', res.status, 'api');
566
+ heldReady = waitS >= 1 && res.status === 200 && /\bwait\s*=/i.test(res.headers.get('preference-applied') ?? '');
567
+ if (heldReady && (out.operation.state !== 'succeeded' || out.workspace.observed_state !== 'running')) {
568
+ throw new ShardfluxProtocolError('fork: held response is not a succeeded running target', res.status, 'api');
569
+ }
570
+ const token = heldReady ? out.tool_token ?? null : null;
571
+ this.#noteToken(token);
572
+ copy = this.#wrap(out.workspace, { agentLabel: opts.agentLabel, tools: opts.tools, token, trace });
573
+ if (wait && waitS >= 1)
574
+ wait.timeoutMs = Math.max(1, timeoutMs - (Date.now() - started));
549
575
  return out.operation;
550
576
  }, {
551
577
  ...opts,
578
+ ...(wait ? { wait } : {}),
552
579
  [AFTER_WAIT]: async (trace, op) => {
553
- await trace.span('view', () => copy.refresh());
580
+ if (!heldReady)
581
+ await trace.span('view', () => copy.refresh());
554
582
  await opts[AFTER_WAIT]?.(trace, op);
555
583
  },
556
- }, { settle: true });
584
+ }, { settle: true, requestReason: wait && wait.serverWait !== false && timeoutMs >= 1000 ? 'held' : null });
557
585
  return { operation, workspace: copy };
558
586
  }
559
587
  /** The workspace's text inputs `{NAME: value}` (0.7.0). Secret inputs are bound secrets, never listed. */
package/dist/errors.d.ts CHANGED
@@ -11,7 +11,7 @@ export type ErrorCode = AppErrorCode | CellErrorCode;
11
11
  * added: 409 conflict legacy_disk_layout, not_session, session_lifetime, lifetime_mismatch,
12
12
  * not_resettable, template_not_layered, draft_exists, draft_stale, build_in_progress, file_list_unavailable,
13
13
  * file_list_indexing (retryable), guest_feature_unavailable; 422 validation_failed confirm_destructive_required,
14
- * reserved_key_prefix, invalid_defaults, update_policy_not_available, invalid_path, too_many_acknowledged_findings;
14
+ * reserved_key_prefix, invalid_defaults, invalid_path, too_many_acknowledged_findings;
15
15
  * 403 forbidden template_dev_mode_role; 404 not_found draft_not_found, version_not_found, path_not_found.
16
16
  * The template editor (0.7.0) added: 422 validation_failed invalid_recipe, base_not_layered,
17
17
  * language_unavailable, language_conflict, invalid_package, too_many_files, platform_owned_path, upload_required,
@@ -51,8 +51,38 @@ export type ErrorCode = AppErrorCode | CellErrorCode;
51
51
  * overage_unavailable, spend_cap_required, spend_cap_below_minimum (details.min_minor), spend_cap_above_plan_price
52
52
  * (details.max_minor) and spend_cap_below_charges (details.charges_minor); with `ifMatch`, 409 conflict
53
53
  * version_mismatch (details.current_version), as egress puts with `ifMatch` answer too.
54
+ * Elastic memory (0.13.0): 422 validation_failed allocation_mode_not_available (details.field
55
+ * `caps.allocation_mode`; the organization does not have elastic memory, nothing is created or changed),
56
+ * not_supported_for_mode (elastic with file_first), requires_elastic and exceeds_memory_mib (details.field
57
+ * `caps.memory_mib_held`).
58
+ * Burst execution (0.13.0): the codes `burst_unavailable` (409; details.reason
59
+ * not_available, layout_unsupported, shared_volumes, host_capacity, fence_not_drained, workspace_fenced, apply_pending,
60
+ * park_failed, workspace_resumed, interrupted; details.replayed on a journaled failure answered again) and
61
+ * `burst_apply_failed` (409; details.reason disk_full, apply_failed, reverted, revert_failed with
62
+ * details.applied_entries and details.pending_entries: retry with the same session id to finish an apply_failed one);
63
+ * 422 validation_failed burst_mode_not_supported (`auto`), burst_not_supported (with stdin; also 409 conflict for
64
+ * signal/cancel of a burst session) and burst_size_exceeds_plan (details.field, details.limit); a burst session nobody
65
+ * followed any more ends with burst.error service_unavailable burst_lost.
66
+ * Immutable paths (0.13.0): 422 validation_failed immutable_path_removed (a recipe drops a path of the template's open
67
+ * version: details.removed, details.open_version; a version keeps every immutable path), immutable_paths_unsupported_base
68
+ * (the base's guest agent lacks the feature, or a Dockerfile build of a template with immutable paths: details.base
69
+ * {name, version}, details.required_feature, details.paths) and invalid_path at details.field `recipe.immutable[<i>]`;
70
+ * 409 conflict read_only_path (a files write under an immutable path, from the cell gateway). The reason
71
+ * `update_policy_not_available` is gone with the update policy. A build that fails on them carries `failure.code`
72
+ * immutable_path_missing (details.path) or immutable_image_too_large.
73
+ * Host loss (0.13.1; the machine a workspace ran on failed, and the workspace restores itself on its next use): the
74
+ * operation error `workspace_storage_unavailable` carries details.reason host_lost (and details.workspace_state
75
+ * `suspended`) when neither the workspace's disk nor a checkpoint could be restored (not retryable). A fork or snapshot
76
+ * of such a workspace before its resume fails with the operation error `resume_required` (details.reason host_lost;
77
+ * not retryable): resume the workspace first.
78
+ * Workspaces working at once (0.14.0): the cell gateway answers a tool call that finds every working slot of the plan
79
+ * taken with 429 `quota_exceeded` (retryable, Retry-After; details.limit `concurrent_workspaces`, limit_value, current,
80
+ * retry_after_seconds); the call did not run and the SDK retries it like any transient refusal. The API's 403
81
+ * `quota_exceeded` (not retryable) names details.limit `concurrent_workspaces` on open, resume and fork, and
82
+ * `retained_state` (details.limit_value and details.current in GiB) when opening a new key or forking with the plan's
83
+ * Retained state used up. `details.limit` is not a reason: these are not in this union.
54
84
  */
55
- export type KnownErrorReason = 'invalid_recipe' | 'base_not_layered' | 'language_unavailable' | 'language_conflict' | 'invalid_package' | 'too_many_files' | 'platform_owned_path' | 'upload_required' | 'upload_missing' | 'upload_digest_mismatch' | 'upload_too_large' | 'extra_hosts_without_auto' | 'invalid_settings' | 'services_unsupported' | 'input_required' | 'input_unknown' | 'input_invalid' | 'egress_widening' | 'reserved_session_id' | 'env_collision' | 'reserved_template_slug' | 'package_index_unavailable' | 'package_not_found' | 'startup_failed' | 'service_not_ready' | 'secrets_unavailable' | 'workspace_not_running' | 'operation_in_progress' | 'workspace_deleted' | 'secret_not_available' | 'legacy_disk_layout' | 'not_session' | 'session_lifetime' | 'lifetime_mismatch' | 'not_resettable' | 'template_not_layered' | 'draft_exists' | 'draft_stale' | 'build_in_progress' | 'file_list_unavailable' | 'file_list_indexing' | 'guest_feature_unavailable' | 'confirm_destructive_required' | 'reserved_key_prefix' | 'invalid_defaults' | 'update_policy_not_available' | 'invalid_path' | 'too_many_acknowledged_findings' | 'template_dev_mode_role' | 'draft_not_found' | 'version_not_found' | 'path_not_found' | 'revision_mismatch' | 'edit_not_found' | 'edit_ambiguous' | 'edit_not_text' | 'patch_invalid' | 'host_capacity' | 'wake_failed' | 'workspace_fenced' | 'offline_unavailable' | 'offline_budget' | 'offline_changed' | 'host_feature_unavailable' | 'not_supported_for_mode' | 'mode_mismatch' | 'mode_not_available' | 'layout_unsupported' | 'tree_revision_mismatch' | 'outside_tree_root' | 'execution_in_progress' | 'execution_id_reused' | 'operation_id_reused' | 'no_execution_host' | 'lease_expired' | 'host_unreachable' | 'host_restarted' | 'tree_moved' | 'blob_missing' | 'blob_corrupt' | 'exec_failed_to_start' | 'invalid_cwd' | 'allowance_used' | 'overage_paused' | 'spend_cap_reached' | 'overage_unavailable' | 'spend_cap_required' | 'spend_cap_below_minimum' | 'spend_cap_above_plan_price' | 'spend_cap_below_charges' | 'version_mismatch';
85
+ export type KnownErrorReason = 'invalid_recipe' | 'base_not_layered' | 'language_unavailable' | 'language_conflict' | 'invalid_package' | 'too_many_files' | 'platform_owned_path' | 'upload_required' | 'upload_missing' | 'upload_digest_mismatch' | 'upload_too_large' | 'extra_hosts_without_auto' | 'invalid_settings' | 'services_unsupported' | 'input_required' | 'input_unknown' | 'input_invalid' | 'egress_widening' | 'reserved_session_id' | 'env_collision' | 'reserved_template_slug' | 'package_index_unavailable' | 'package_not_found' | 'startup_failed' | 'service_not_ready' | 'secrets_unavailable' | 'workspace_not_running' | 'operation_in_progress' | 'workspace_deleted' | 'secret_not_available' | 'legacy_disk_layout' | 'not_session' | 'session_lifetime' | 'lifetime_mismatch' | 'not_resettable' | 'template_not_layered' | 'draft_exists' | 'draft_stale' | 'build_in_progress' | 'file_list_unavailable' | 'file_list_indexing' | 'guest_feature_unavailable' | 'confirm_destructive_required' | 'reserved_key_prefix' | 'invalid_defaults' | 'invalid_path' | 'too_many_acknowledged_findings' | 'template_dev_mode_role' | 'draft_not_found' | 'version_not_found' | 'path_not_found' | 'revision_mismatch' | 'edit_not_found' | 'edit_ambiguous' | 'edit_not_text' | 'patch_invalid' | 'host_capacity' | 'wake_failed' | 'workspace_fenced' | 'offline_unavailable' | 'offline_budget' | 'offline_changed' | 'host_feature_unavailable' | 'not_supported_for_mode' | 'mode_mismatch' | 'mode_not_available' | 'layout_unsupported' | 'tree_revision_mismatch' | 'outside_tree_root' | 'execution_in_progress' | 'execution_id_reused' | 'operation_id_reused' | 'no_execution_host' | 'lease_expired' | 'host_unreachable' | 'host_restarted' | 'tree_moved' | 'blob_missing' | 'blob_corrupt' | 'exec_failed_to_start' | 'invalid_cwd' | 'allowance_used' | 'overage_paused' | 'spend_cap_reached' | 'overage_unavailable' | 'spend_cap_required' | 'spend_cap_below_minimum' | 'spend_cap_above_plan_price' | 'spend_cap_below_charges' | 'version_mismatch' | 'allocation_mode_not_available' | 'requires_elastic' | 'exceeds_memory_mib' | 'burst_mode_not_supported' | 'burst_not_supported' | 'burst_size_exceeds_plan' | 'not_available' | 'shared_volumes' | 'fence_not_drained' | 'apply_pending' | 'park_failed' | 'workspace_resumed' | 'interrupted' | 'burst_lost' | 'disk_full' | 'apply_failed' | 'reverted' | 'revert_failed' | 'immutable_path_removed' | 'immutable_paths_unsupported_base' | 'read_only_path' | 'host_lost';
56
86
  /** A known reason, or any other string the server sends (reasons are open-ended). */
57
87
  export type ErrorReason = KnownErrorReason | (string & {});
58
88
  export interface ErrorBodyLike {
@@ -87,6 +117,13 @@ export declare class ShardfluxApiError extends Error {
87
117
  readonly treeRevision: number | undefined;
88
118
  constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number);
89
119
  }
120
+ /**
121
+ * The cell gateway's working-at-once refusal (0.14.0+): 429 `quota_exceeded`, retryable, with Retry-After. The gateway
122
+ * refuses the call at admission, before the workspace is touched, so nothing ran and any request (exec start, stdin,
123
+ * PTY create, keepalive included) may be sent again. The API's 403 `quota_exceeded` is a different refusal (not
124
+ * retryable) and never matches.
125
+ */
126
+ export declare function isWorkingQuotaRefusal(err: unknown): err is ShardfluxApiError;
90
127
  /** processful (the default: one VM keeps processes, memory and files) or file_first. */
91
128
  export type WorkspaceMode = AppComponents['schemas']['WorkspaceMode'];
92
129
  /**
@@ -178,6 +215,7 @@ export declare class OperationTimeoutError extends Error {
178
215
  * The operation reached `failed` or `canceled`. A suspend-when-idle that found the workspace active (a tool call after
179
216
  * the request, an attached stream) is `canceled` with `errorCode` `workspace_active` (`workspaceActive` true, 0.12.0+):
180
217
  * nothing changed and the workspace keeps running. `waitUntilReady()` and `wake()` treat it as running, not as a failure.
218
+ * `resume_required` (0.13.1+): a fork or snapshot of a workspace whose machine failed; resume it, then call again.
181
219
  */
182
220
  export declare class OperationFailedError extends Error {
183
221
  readonly operation: Operation;
package/dist/errors.js CHANGED
@@ -40,6 +40,15 @@ export class ShardfluxApiError extends Error {
40
40
  this.reason = typeof reason === 'string' ? reason : undefined;
41
41
  }
42
42
  }
43
+ /**
44
+ * The cell gateway's working-at-once refusal (0.14.0+): 429 `quota_exceeded`, retryable, with Retry-After. The gateway
45
+ * refuses the call at admission, before the workspace is touched, so nothing ran and any request (exec start, stdin,
46
+ * PTY create, keepalive included) may be sent again. The API's 403 `quota_exceeded` is a different refusal (not
47
+ * retryable) and never matches.
48
+ */
49
+ export function isWorkingQuotaRefusal(err) {
50
+ return err instanceof ShardfluxApiError && err.source === 'cell' && err.status === 429 && err.code === 'quota_exceeded' && err.retryable;
51
+ }
43
52
  /**
44
53
  * The call does not exist for the workspace's mode: 409 `conflict` with details.reason
45
54
  * `not_supported_for_mode`, `details.mode` (the workspace's mode) and `details.operation`. File-first workspaces have no
@@ -187,6 +196,7 @@ export class OperationTimeoutError extends Error {
187
196
  * The operation reached `failed` or `canceled`. A suspend-when-idle that found the workspace active (a tool call after
188
197
  * the request, an attached stream) is `canceled` with `errorCode` `workspace_active` (`workspaceActive` true, 0.12.0+):
189
198
  * nothing changed and the workspace keeps running. `waitUntilReady()` and `wake()` treat it as running, not as a failure.
199
+ * `resume_required` (0.13.1+): a fork or snapshot of a workspace whose machine failed; resume it, then call again.
190
200
  */
191
201
  export class OperationFailedError extends Error {
192
202
  operation;