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