@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/CHANGELOG.md +172 -2
- package/README.md +131 -15
- package/dist/account.d.ts +41 -2
- package/dist/account.js +36 -2
- package/dist/cell.d.ts +21 -4
- package/dist/cell.js +67 -10
- package/dist/client.d.ts +86 -2
- package/dist/client.js +148 -0
- package/dist/codex-proof.d.ts +53 -0
- package/dist/codex-proof.js +200 -0
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +10 -0
- package/dist/generated/app-api.d.ts +1995 -255
- package/dist/generated/cell-api.d.ts +19 -9
- package/dist/http.d.ts +40 -3
- package/dist/http.js +74 -10
- package/dist/index.d.ts +9 -5
- package/dist/index.js +3 -1
- package/dist/ports.d.ts +117 -0
- package/dist/ports.js +62 -0
- package/dist/progress.d.ts +52 -8
- package/dist/progress.js +37 -1
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +20 -0
- package/dist/usage.d.ts +3 -1
- package/dist/usage.js +3 -1
- package/dist/workspace.d.ts +43 -7
- package/dist/workspace.js +41 -3
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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`
|
|
162
|
-
* the
|
|
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>;
|