@shardflux/sdk 0.13.0 → 0.14.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
@@ -1,4 +1,4 @@
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";
@@ -20,8 +20,13 @@ export const DEFAULT_TRANSITION_TIMEOUT_MS = 120_000;
20
20
  * capture writes recorded before them (read-your-writes). Workspace.cell() sets it; the capture's own client does not.
21
21
  */
22
22
  export const CAPTURE_BARRIER = Symbol('shardflux.captureBarrier');
23
- /** 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
+ */
24
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;
25
30
  /** exec.cancel grace: the workspace waits 5 s without one and takes at most 60 s. */
26
31
  const DEFAULT_CANCEL_GRACE_MS = 5_000;
27
32
  const MAX_CANCEL_GRACE_MS = 60_000;
@@ -63,6 +68,30 @@ export async function* ndjson(body) {
63
68
  reader.releaseLock();
64
69
  }
65
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
+ }
66
95
  class ByteSink {
67
96
  #max;
68
97
  #chunks = [];
@@ -108,6 +137,10 @@ const executionRetryDelayMs = (failures, retryAfterSeconds) => retryAfterSeconds
108
137
  const attemptTimedOut = (err) => err instanceof Error && err.name === 'TimeoutError';
109
138
  /** A transport failure or a retryable 429/5xx: the execution request may be sent again with the same id. */
110
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;
111
144
  if (err instanceof ShardfluxApiError) {
112
145
  const status = err.status === 429 || err.status === 502 || err.status === 503 || err.status === 504;
113
146
  return status && err.retryable ? { retryAfterSeconds: err.retryAfterSeconds } : null;
@@ -213,8 +246,10 @@ export class CellClient {
213
246
  * One authorized request. Refreshes the token once on stale_epoch / 401. Lifecycle transitions,
214
247
  * bounded in total by `transitionTimeoutMs`: a call refused with `workspace_busy` is retried after `Retry-After`; one
215
248
  * refused with `workspace_not_running` (or whose token cannot be minted because the workspace is not running) wakes
216
- * the workspace through `wake`, given the time left, and is retried with a fresh token, at most 3 wakes per call.
217
- * 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.
218
253
  */
219
254
  async request(method, path, init = {}) {
220
255
  const closer = this.#closer.signal;
@@ -237,6 +272,7 @@ export class CellClient {
237
272
  const retry = (cause, delayMs, attempt) => emit?.({ type: 'retry', retry: { atMs: Math.round((performance.now() - t0) * 10) / 10, request: `${method} ${path}`, attempt, cause, delayMs } });
238
273
  let refreshed = false;
239
274
  let wakes = 0;
275
+ let runningAgain = 0;
240
276
  let attempts = 0;
241
277
  for (;;) {
242
278
  try {
@@ -270,8 +306,17 @@ export class CellClient {
270
306
  if (notRunning && wakeAllowed && this.#wake && wakes < MAX_WAKES && left > 0) {
271
307
  wakes += 1;
272
308
  const before = this.tokens.current;
273
- if ((await this.#wake(left, signal)) === false)
274
- 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
+ }
275
320
  // A held resume handed this client a token of the woken workspace: use it. Otherwise the
276
321
  // old token is of the previous epoch: fetch a new one.
277
322
  if (this.tokens.current === before)
@@ -399,6 +444,8 @@ export class CellClient {
399
444
  req.kill_grace_ms = opts.killGraceMs;
400
445
  if (opts.secretRefs !== undefined)
401
446
  req.secret_refs = opts.secretRefs;
447
+ if (opts.resourceHint !== undefined)
448
+ req.resource_hint = opts.resourceHint;
402
449
  if (opts.burst !== undefined)
403
450
  req.burst = opts.burst;
404
451
  if (opts.burstVcpus !== undefined)
@@ -744,6 +791,7 @@ export class CellClient {
744
791
  let next = opts.offset ?? 0;
745
792
  let session = null;
746
793
  let exited = false;
794
+ let refusal;
747
795
  await new Promise((resolve, reject) => {
748
796
  const onClose = () => finish(this.#closer.signal.reason instanceof Error ? this.#closer.signal.reason : new Error('closed'));
749
797
  const ws = new WS(url, { headers: { authorization: `Bearer ${token}` } });
@@ -760,8 +808,10 @@ export class CellClient {
760
808
  catch {
761
809
  // already closed
762
810
  }
763
- if (err)
764
- reject(err);
811
+ // A refusal body whose close did not arrive within the read: the body alone says it.
812
+ const failure = err ?? (refusal ? attachRefusal(undefined, undefined, refusal) : undefined);
813
+ if (failure)
814
+ reject(failure);
765
815
  else
766
816
  resolve();
767
817
  };
@@ -775,6 +825,12 @@ export class CellClient {
775
825
  if (typeof e.data !== 'string')
776
826
  return;
777
827
  const m = JSON.parse(e.data);
828
+ // A refused attach (e.g. 4429 quota_exceeded: every working slot is taken): the bare ErrorBody, then the close.
829
+ if (!('type' in m)) {
830
+ if (isErrorBody(m))
831
+ refusal = m;
832
+ return;
833
+ }
778
834
  if (m.type === 'output' && m.data) {
779
835
  const bytes = unb64(m.data);
780
836
  sink.push(bytes);
@@ -791,7 +847,7 @@ export class CellClient {
791
847
  }
792
848
  });
793
849
  ws.addEventListener('error', () => finish(new ShardfluxProtocolError('pty attach WebSocket failed', 0, 'cell')));
794
- ws.addEventListener('close', () => finish());
850
+ ws.addEventListener('close', (e) => finish(attachRefusal(e.code, e.reason, refusal)));
795
851
  if (this.closed)
796
852
  onClose();
797
853
  else
@@ -955,7 +1011,8 @@ export class CellClient {
955
1011
  // Nothing of a file-first workspace sleeps: the cell would answer `resident`.
956
1012
  if (this.mode === 'file_first')
957
1013
  return Promise.resolve({ residency: 'resident' });
958
- return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/wake-hint'), { wake: false, busy: false, timeoutMs: 10_000, ...(signal ? { signal } : {}) });
1014
+ // A hint never waits: a 429 quota_exceeded (every working slot is taken) surfaces at once, like its other refusals.
1015
+ return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/wake-hint'), { wake: false, busy: false, quotaRetry: false, timeoutMs: 10_000, ...(signal ? { signal } : {}) });
959
1016
  }
960
1017
  /** Read idle signals without recording activity or waking the workspace. */
961
1018
  idle(signal) {
package/dist/client.d.ts CHANGED
@@ -16,6 +16,7 @@ import type { ToolName, ToolToken } from './tokens.js';
16
16
  import { Workspace } from './workspace.js';
17
17
  import { AuditApi } from './audit.js';
18
18
  import { EgressPolicyApi } from './egress.js';
19
+ import { WorkspacePorts } from './ports.js';
19
20
  import { SecretsApi } from './secrets.js';
20
21
  import { TemplatesApi } from './templates.js';
21
22
  import type { SaveAsTemplateParams, SaveAsTemplateResponse } from './templates.js';
@@ -158,8 +159,9 @@ export interface Caps {
158
159
  memory_mib?: number;
159
160
  disk_gib?: number;
160
161
  /**
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
162
+ * `fixed` or `elastic` (0.13.0). Omitted: fixed, or elastic where that is the organization's default (a reopen then
163
+ * keeps the stored mode); the SDK never fills it in. Given caps replace the stored ones; omitted caps keep the stored
164
+ * layout. `workspace.resize()` (0.14.0+) changes it on a running or suspended workspace. Elastic needs the organization's entitlement, else
163
165
  * ShardfluxApiError 422 `validation_failed` reason `allocation_mode_not_available` (nothing is created or changed);
164
166
  * a file-first workspace gets `not_supported_for_mode`. A promise that does not exceed the held floor by at least
165
167
  * 512 MiB is fixed at the promise (the returned `caps.allocation_mode` says which). Takes effect at the next VM start.
@@ -176,6 +178,64 @@ export interface ForkTarget {
176
178
  caps?: Caps;
177
179
  lifetime?: WorkspaceLifetime;
178
180
  }
181
+ /**
182
+ * `workspace.resize()` / `workspaces.resize()` (0.14.0): the caps to change. Give at least one; the others keep their
183
+ * stored values. Each value is bounded by the plan's per-workspace maximum and the template's limit, as caps at open.
184
+ */
185
+ export interface ResizeParams {
186
+ /** Memory in MiB: the size of a fixed workspace, the maximum an elastic one may grow to. */
187
+ memoryMib?: number;
188
+ /** Elastic only: the memory the workspace holds while idle, in MiB (at most the resulting `memoryMib`). */
189
+ memoryMibHeld?: number;
190
+ /** `fixed` or `elastic`: switches the mode, live on a running workspace. */
191
+ allocationMode?: AllocationMode;
192
+ /** CPU in millicores (1000 = one vCPU). */
193
+ cpuMillis?: number;
194
+ /** Disk in GiB; disks grow only. */
195
+ diskGib?: number;
196
+ /** Replays the stored response for a repeated request (default: a fresh key per call, so transport retries replay). */
197
+ idempotencyKey?: string;
198
+ /** Give up waiting for the resize after this long (default 300 000 ms); it continues server side. */
199
+ timeoutMs?: number;
200
+ signal?: AbortSignal;
201
+ /** Progress of the resize (the held request, the operation's states, retries, `done` with the timing). */
202
+ onProgress?: ProgressListener;
203
+ }
204
+ /** The held answer of `PATCH /v1/workspaces/{id}/caps` (and a finished resize operation's `result`). */
205
+ export type ResizeResponse = components['schemas']['ResizeResult'];
206
+ /** The memory part of a resize result (0.14.0). `*_mib` are MiB; `applied_mib` is what is live after the call (null when no VM runs), `held_mib` the elastic floor, `converged` whether a live change reached its target. */
207
+ export type ResizeMemory = NonNullable<ResizeResponse['memory']>;
208
+ /** The CPU part of a resize result (0.14.0), in millicores. */
209
+ export type ResizeCpu = NonNullable<ResizeResponse['cpu']>;
210
+ /** The disk part of a resize result (0.14.0), in GiB. */
211
+ export type ResizeDisk = NonNullable<ResizeResponse['disk']>;
212
+ /** When a resized resource takes effect (0.14.0): `now`, when the suspended workspace resumes, or at its next start. */
213
+ export type ResizeAppliesAt = ResizeMemory['applies_at'];
214
+ /** Why less than requested applied: clamped to the plan or template bound, the host had no room, or the guest kept memory it uses. */
215
+ export type ResizeLimitReason = NonNullable<ResizeMemory['limit_reason']>;
216
+ /**
217
+ * Why a resource applies later than now: `suspended` (at resume), `stopped` (at the next start), `boot_vcpus` (more CPU
218
+ * than the vCPUs the workspace booted with), `region` / `below_base` / `legacy_layout` (a memory size its running VM was
219
+ * not booted for).
220
+ */
221
+ export type ResizeDeferReason = NonNullable<ResizeMemory['reason']>;
222
+ /**
223
+ * What a resize did (0.14.0), per resource the request named (the others are null): when it applies (`applies_at`),
224
+ * what was asked, the bounded target, the size before, what is live now, and why less or later.
225
+ */
226
+ export interface ResizeResult {
227
+ workspaceId: string;
228
+ operationId: string | null;
229
+ /** The workspace's state the result describes: `running`, `suspended` or `stopped`. */
230
+ state: string;
231
+ memory: ResizeMemory | null;
232
+ cpu: ResizeCpu | null;
233
+ disk: ResizeDisk | null;
234
+ /** The workspace's stored caps after the change: every later start uses them. */
235
+ caps: ResizeResponse['caps'] | null;
236
+ /** The finished `resize` operation when the API answered before it was done (202), else null. */
237
+ operation: Operation | null;
238
+ }
179
239
  export interface WaitOptions {
180
240
  /**
181
241
  * Give up waiting after this long (default 300 000 ms); the operation continues server side. A queued start
@@ -338,6 +398,8 @@ export declare class WorkspacesApi {
338
398
  agentLabel?: string;
339
399
  tools?: ToolName[];
340
400
  }): Promise<Workspace>;
401
+ /** The exposed ports of a workspace by id (0.14.0; the same as `workspace.ports` without reading the workspace first). */
402
+ ports(workspaceId: string): WorkspacePorts;
341
403
  /** Replace labels. An empty map clears them. */
342
404
  setLabels(workspaceId: string, labels: Record<string, string>): Promise<Workspace>;
343
405
  /** null clears the override, restoring the template or platform policy. */
@@ -412,6 +474,28 @@ export declare class WorkspacesApi {
412
474
  * is not undone: it is the workspace's `activeOperation` (resume or open the workspace instead).
413
475
  */
414
476
  cancelSuspendWhenIdle(workspaceId: string): Promise<Workspace>;
477
+ /**
478
+ * Resizes a workspace (0.14.0; `PATCH /v1/workspaces/{id}/caps`): any of memory, the held floor, the allocation mode,
479
+ * CPU and disk, of a running, suspended or stopped workspace, fixed or elastic, without a restart or a fork. The new
480
+ * caps are stored, so every later start uses them. Resolves once the resize has finished, with what applied per
481
+ * resource: `now` (live), `resume` (a suspended workspace gets it when it resumes, before its first call) or
482
+ * `next_start` (with the `reason`). The request is held by the server while the resize runs; a resize still running
483
+ * after the hold is waited for like any operation.
484
+ *
485
+ * A resize that meets another lifecycle operation (409 `operation_in_progress`: a suspend, a resume, another resize)
486
+ * waits for it and is sent again, within `timeoutMs`.
487
+ *
488
+ * Errors: ShardfluxApiError 409 `conflict` (reason `resize_not_available`, `workspace_deleted`), 422
489
+ * `validation_failed` (reason `shrink_not_supported` with `details.current_disk_gib`, `requires_elastic`,
490
+ * `exceeds_memory_mib`, `allocation_mode_not_available`, `not_supported_for_mode`). A resize that fails is its
491
+ * error: ShardfluxApiError with `operationId` when the server held the request until then, OperationFailedError when
492
+ * the SDK waited for the operation. OperationTimeoutError after `timeoutMs`. Params without a resource throw
493
+ * TypeError before any request.
494
+ *
495
+ * const r = await cloud.workspaces.resize(id, { memoryMib: 6144 });
496
+ * r.memory?.applies_at; // 'now'
497
+ */
498
+ resize(workspaceId: string, params: ResizeParams): Promise<ResizeResult>;
415
499
  /**
416
500
  * Ends a session workspace now: the workspace is deleted exactly like delete() (ended_reason
417
501
  * closed) and returns the `delete` operation (input.reason session_closed); the key then opens a NEW workspace.
package/dist/client.js CHANGED
@@ -3,6 +3,7 @@ import { HttpClient, SDK_VERSION, SERVER_WAIT_MAX_S, defaultFetch, defaultSleep,
3
3
  import { Workspace } from "./workspace.js";
4
4
  import { AuditApi } from "./audit.js";
5
5
  import { EgressPolicyApi } from "./egress.js";
6
+ import { WorkspacePorts } from "./ports.js";
6
7
  import { SecretsApi } from "./secrets.js";
7
8
  import { TemplatesApi, saveAsTemplateBody } from "./templates.js";
8
9
  import { UsageApi } from "./usage.js";
@@ -46,6 +47,70 @@ function alreadyRunning(answer) {
46
47
  },
47
48
  }, 'api');
48
49
  }
50
+ /** The `PATCH .../caps` body of a resize: the given caps only, snake_case. Throws TypeError when none is given. */
51
+ function resizeBody(p) {
52
+ const body = {};
53
+ if (p.memoryMib !== undefined)
54
+ body.memory_mib = p.memoryMib;
55
+ if (p.memoryMibHeld !== undefined)
56
+ body.memory_mib_held = p.memoryMibHeld;
57
+ if (p.allocationMode !== undefined)
58
+ body.allocation_mode = p.allocationMode;
59
+ if (p.cpuMillis !== undefined)
60
+ body.cpu_millis = p.cpuMillis;
61
+ if (p.diskGib !== undefined)
62
+ body.disk_gib = p.diskGib;
63
+ if (Object.keys(body).length === 0)
64
+ throw new TypeError('resize() needs at least one of memoryMib, memoryMibHeld, allocationMode, cpuMillis, diskGib');
65
+ return body;
66
+ }
67
+ const RESIZE_KEYS = {
68
+ memory: ['applies_at', 'allocation_mode', 'requested_mib', 'target_mib', 'previous_mib', 'applied_mib', 'held_mib', 'converged', 'limit_reason', 'reason'],
69
+ cpu: ['applies_at', 'requested_millis', 'target_millis', 'previous_millis', 'applied_millis', 'limit_reason', 'reason'],
70
+ disk: ['applies_at', 'requested_gib', 'target_gib', 'previous_gib', 'applied_gib', 'limit_reason', 'reason'],
71
+ };
72
+ const record = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) ? v : {});
73
+ /**
74
+ * A finished resize operation's `result` in the held answer's shape (as the API builds its 200): each resource the
75
+ * cell reported with its documented keys, a key the cell left out taken from the operation's input (target, requested)
76
+ * or null; the cell's other keys (timings, the previous values of a retried attempt) are left out.
77
+ */
78
+ function resizeResultBodyOf(op) {
79
+ const result = record(op.result);
80
+ const input = record(op.input);
81
+ const target = record(input.target);
82
+ const requested = record(input.requested);
83
+ const fallback = {
84
+ memory: { allocation_mode: target.allocation_mode, requested_mib: requested.memory_mib, target_mib: target.memory_mib, held_mib: target.memory_mib_held },
85
+ cpu: { requested_millis: requested.cpu_millis, target_millis: target.cpu_millis },
86
+ disk: { requested_gib: requested.disk_gib, target_gib: target.disk_gib },
87
+ };
88
+ const out = { workspace_id: result.workspace_id ?? op.workspace_id, operation_id: op.id, state: result.state };
89
+ for (const [name, keys] of Object.entries(RESIZE_KEYS)) {
90
+ if (result[name] === null || typeof result[name] !== 'object')
91
+ continue;
92
+ const r = record(result[name]);
93
+ out[name] = Object.fromEntries(keys.map((k) => [k, r[k] ?? fallback[name]?.[k] ?? null]));
94
+ }
95
+ return out;
96
+ }
97
+ /** A resize result body (the held 200, or a finished operation's `result`) as ResizeResult. */
98
+ function resizeResultOf(body, status, operation, workspaceId) {
99
+ if (!body || typeof body !== 'object' || !['memory', 'cpu', 'disk'].some((k) => body[k] && typeof body[k] === 'object')) {
100
+ throw new ShardfluxProtocolError('resize: the result names no resource (memory, cpu or disk)', status, 'api');
101
+ }
102
+ const part = (k) => (body[k] && typeof body[k] === 'object' ? body[k] : null);
103
+ return {
104
+ workspaceId: typeof body.workspace_id === 'string' ? body.workspace_id : workspaceId,
105
+ operationId: typeof body.operation_id === 'string' ? body.operation_id : (operation?.id ?? null),
106
+ state: typeof body.state === 'string' ? body.state : 'unknown',
107
+ memory: part('memory'),
108
+ cpu: part('cpu'),
109
+ disk: part('disk'),
110
+ caps: body.caps && typeof body.caps === 'object' ? body.caps : null,
111
+ operation,
112
+ };
113
+ }
49
114
  /** Races the injected sleep against the signal (the injected sleep itself may not be abortable). */
50
115
  function abortableSleep(sleep, ms, signal) {
51
116
  if (signal.aborted)
@@ -316,6 +381,10 @@ export class WorkspacesApi {
316
381
  async get(workspaceId, opts = {}) {
317
382
  return this.#wrap(await this.#getView(workspaceId), opts);
318
383
  }
384
+ /** The exposed ports of a workspace by id (0.14.0; the same as `workspace.ports` without reading the workspace first). */
385
+ ports(workspaceId) {
386
+ return new WorkspacePorts(this.#ctx(), workspaceId);
387
+ }
319
388
  /** Replace labels. An empty map clears them. */
320
389
  async setLabels(workspaceId, labels) {
321
390
  return this.#wrap(await this.#http.json('PUT', `/v1/workspaces/${encodeURIComponent(workspaceId)}/labels`, { json: { labels } }, this.#auth));
@@ -514,6 +583,85 @@ export class WorkspacesApi {
514
583
  async cancelSuspendWhenIdle(workspaceId) {
515
584
  return this.#wrap(await this.#http.json('DELETE', `/v1/workspaces/${encodeURIComponent(workspaceId)}/suspend-when-idle`, {}, this.#auth));
516
585
  }
586
+ /**
587
+ * Resizes a workspace (0.14.0; `PATCH /v1/workspaces/{id}/caps`): any of memory, the held floor, the allocation mode,
588
+ * CPU and disk, of a running, suspended or stopped workspace, fixed or elastic, without a restart or a fork. The new
589
+ * caps are stored, so every later start uses them. Resolves once the resize has finished, with what applied per
590
+ * resource: `now` (live), `resume` (a suspended workspace gets it when it resumes, before its first call) or
591
+ * `next_start` (with the `reason`). The request is held by the server while the resize runs; a resize still running
592
+ * after the hold is waited for like any operation.
593
+ *
594
+ * A resize that meets another lifecycle operation (409 `operation_in_progress`: a suspend, a resume, another resize)
595
+ * waits for it and is sent again, within `timeoutMs`.
596
+ *
597
+ * Errors: ShardfluxApiError 409 `conflict` (reason `resize_not_available`, `workspace_deleted`), 422
598
+ * `validation_failed` (reason `shrink_not_supported` with `details.current_disk_gib`, `requires_elastic`,
599
+ * `exceeds_memory_mib`, `allocation_mode_not_available`, `not_supported_for_mode`). A resize that fails is its
600
+ * error: ShardfluxApiError with `operationId` when the server held the request until then, OperationFailedError when
601
+ * the SDK waited for the operation. OperationTimeoutError after `timeoutMs`. Params without a resource throw
602
+ * TypeError before any request.
603
+ *
604
+ * const r = await cloud.workspaces.resize(id, { memoryMib: 6144 });
605
+ * r.memory?.applies_at; // 'now'
606
+ */
607
+ async resize(workspaceId, params) {
608
+ const body = resizeBody(params);
609
+ const trace = new Trace('resize', combineListeners(this.#ctx().onProgress, params.onProgress), { workspaceId });
610
+ return traced(trace, async () => {
611
+ const timeoutMs = params.timeoutMs ?? 300_000;
612
+ const started = Date.now();
613
+ const left = () => Math.max(1, timeoutMs - (Date.now() - started));
614
+ const idempotencyKey = params.idempotencyKey ?? randomId('op-');
615
+ const path = `/v1/workspaces/${encodeURIComponent(workspaceId)}/caps`;
616
+ let res;
617
+ for (let attempt = 0;; attempt += 1) {
618
+ const waitS = Math.min(SERVER_WAIT_MAX_S, Math.floor((attempt === 0 ? timeoutMs : left()) / 1000));
619
+ const init = { json: body, idempotencyKey, onRetry: trace.onRetry, ...(params.signal ? { signal: params.signal } : {}) };
620
+ if (waitS >= 1) {
621
+ // Held: the server answers once the resize has finished (200) or the wait elapsed (202).
622
+ init.headers = { prefer: `wait=${waitS}` };
623
+ init.timeoutMs = Math.max(this.#http.opts.timeoutMs, waitS * 1000 + 10_000);
624
+ }
625
+ trace.phase('request', waitS >= 1 ? 'held' : attempt === 0 ? null : 'retry');
626
+ try {
627
+ res = await this.#http.jsonWithStatus('PATCH', path, init, this.#auth);
628
+ break;
629
+ }
630
+ catch (e) {
631
+ // Another lifecycle operation (a suspend, a resume, another resize) holds the workspace: the refused request
632
+ // changed nothing, so wait for that operation and send it again (bounded, within timeoutMs).
633
+ const active = e instanceof ShardfluxApiError && e.code === 'conflict' && e.reason === 'operation_in_progress' ? (e.operationId ?? e.details?.['active_operation_id']) : undefined;
634
+ if (typeof active !== 'string' || attempt >= 3 || Date.now() - started >= timeoutMs)
635
+ throw e;
636
+ trace.retry({ request: `PATCH ${path}`, attempt: attempt + 1, cause: `conflict operation_in_progress (waits for ${active})`, delayMs: 0 });
637
+ try {
638
+ await this.waitForOperation(active, { timeoutMs: left(), ...(params.signal ? { signal: params.signal } : {}), [TRACE]: trace });
639
+ }
640
+ catch (w) {
641
+ if (!(w instanceof OperationFailedError))
642
+ throw w;
643
+ }
644
+ }
645
+ }
646
+ if (res.status !== 202)
647
+ return resizeResultOf(res.body, res.status, null, workspaceId);
648
+ const operation = res.body?.operation;
649
+ if (!operation || typeof operation !== 'object')
650
+ throw new ShardfluxProtocolError('resize: 202 response has no operation', res.status, 'api');
651
+ trace.observe(operation);
652
+ let final = operation;
653
+ if (operation.state !== 'succeeded') {
654
+ if (TERMINAL.has(operation.state))
655
+ throw new OperationFailedError(operation);
656
+ const wait = { timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)), ...(params.signal ? { signal: params.signal } : {}), [TRACE]: trace };
657
+ final = await this.waitForOperation(operation.id, wait);
658
+ }
659
+ // The finished operation's result is the cell's: shaped like the held answer, whose caps come from the view.
660
+ const result = resizeResultBodyOf(final);
661
+ const view = await trace.span('view', () => this.#getView(workspaceId, trace.onRetry));
662
+ return resizeResultOf({ ...result, state: result.state ?? view.observed_state, caps: view.caps }, res.status, final, workspaceId);
663
+ });
664
+ }
517
665
  async close(workspaceId, opts = {}) {
518
666
  return (await this.closeWithView(workspaceId, opts)).operation;
519
667
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Codex identity proof (0.14.0+, Node): the OpenAI ID token of the Codex CLI signed in on this machine, for
3
+ * `ShardfluxAccount.signup({ codexIdToken })` and `auth.stepUp({ codexIdToken })`.
4
+ *
5
+ * Codex keeps the token in `$CODEX_HOME/auth.json` (default `~/.codex/auth.json`) and refreshes it only every few
6
+ * days, while the API accepts one issued in the last 15 minutes. So this first asks Codex to refresh its own login
7
+ * (`codex app-server`, JSON-RPC `account/read {"refreshToken": true}`: Codex runs its normal refresh flow and writes
8
+ * its own file), then reads the token. Only the ID token is returned: Codex's access and refresh tokens are never read
9
+ * into memory beyond parsing the file, and never leave the machine. The ID token proves the ChatGPT account's verified
10
+ * email; it is not a credential to OpenAI.
11
+ *
12
+ * const proof = await codexIdentityProof();
13
+ * if (proof.ok) {
14
+ * const { account, result } = await ShardfluxAccount.signup({ codexIdToken: proof.idToken });
15
+ * }
16
+ */
17
+ export type CodexProofUnavailable =
18
+ /** No `codex` executable on PATH (or CODEX_BIN), and no usable auth.json. */
19
+ 'codex_not_found'
20
+ /** Codex is not signed in (no auth.json, or no ID token in it: signed in with an API key, or credentials in the OS keyring). */
21
+ | 'not_signed_in'
22
+ /** Codex is signed in with an OpenAI API key, which carries no identity. */
23
+ | 'api_key_login'
24
+ /** The token on disk is too old and Codex could not refresh it. */
25
+ | 'stale';
26
+ export type CodexIdentityProof = {
27
+ ok: true;
28
+ idToken: string;
29
+ email: string | null;
30
+ refreshed: boolean;
31
+ } | {
32
+ ok: false;
33
+ reason: CodexProofUnavailable;
34
+ message: string;
35
+ };
36
+ export interface CodexProofOptions {
37
+ /** Environment to read CODEX_HOME, CODEX_BIN and HOME from. Default `process.env`. */
38
+ env?: Record<string, string | undefined>;
39
+ /** Longest wait for `codex app-server` (ms). Default 15 000. */
40
+ timeoutMs?: number;
41
+ /** Skip the refresh and only read the file (tests, or when Codex must not be started). */
42
+ refresh?: boolean;
43
+ /** Accept a token issued at most this long ago (s), e.g. when Codex cannot refresh. Default 600 (the API allows 900). */
44
+ maxAgeSeconds?: number;
45
+ /** clientInfo sent to the app server. */
46
+ clientName?: string;
47
+ clientVersion?: string;
48
+ }
49
+ /**
50
+ * The ID token of this machine's Codex login, refreshed by Codex when needed. Never throws: `{ ok: false, reason }`
51
+ * says why there is none (sign up with an email address instead).
52
+ */
53
+ export declare function codexIdentityProof(opts?: CodexProofOptions): Promise<CodexIdentityProof>;