@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/CHANGELOG.md +189 -1
- package/README.md +118 -11
- package/dist/cell.d.ts +51 -4
- package/dist/cell.js +138 -22
- package/dist/client.d.ts +30 -6
- package/dist/client.js +33 -5
- package/dist/errors.d.ts +40 -2
- package/dist/errors.js +10 -0
- package/dist/generated/app-api.d.ts +351 -55
- package/dist/generated/cell-api.d.ts +172 -10
- package/dist/http.d.ts +45 -3
- package/dist/http.js +85 -12
- package/dist/index.d.ts +6 -6
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +7 -1
- package/dist/lifecycle.js +2 -2
- package/dist/progress.d.ts +47 -7
- package/dist/progress.js +35 -1
- package/dist/templates.d.ts +25 -3
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +275 -7
- package/dist/usage.d.ts +3 -1
- package/dist/usage.js +3 -1
- package/dist/workspace.d.ts +36 -10
- package/dist/workspace.js +27 -3
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
|
|
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 }), {
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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?:
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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,
|
|
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' | '
|
|
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;
|