@shardflux/sdk 0.8.0 → 0.10.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 +258 -0
- package/README.md +338 -7
- package/dist/account.d.ts +493 -0
- package/dist/account.js +641 -0
- package/dist/cell.d.ts +203 -8
- package/dist/cell.js +457 -32
- package/dist/client.d.ts +120 -5
- package/dist/client.js +142 -6
- package/dist/errors.d.ts +90 -3
- package/dist/errors.js +93 -1
- package/dist/executions.d.ts +120 -0
- package/dist/executions.js +99 -0
- package/dist/feedback.d.ts +67 -0
- package/dist/feedback.js +39 -0
- package/dist/generated/app-api.d.ts +10546 -5855
- package/dist/generated/cell-api.d.ts +501 -9
- package/dist/http.d.ts +7 -1
- package/dist/http.js +26 -7
- package/dist/index.d.ts +18 -7
- package/dist/index.js +5 -1
- package/dist/lifecycle.d.ts +27 -2
- package/dist/lifecycle.js +5 -0
- package/dist/progress.js +4 -2
- package/dist/templates.js +2 -2
- package/dist/tools.d.ts +28 -1
- package/dist/tools.js +172 -21
- package/dist/usage.d.ts +36 -6
- package/dist/usage.js +19 -4
- package/dist/version-check.d.ts +101 -0
- package/dist/version-check.js +191 -0
- package/dist/workspace.d.ts +100 -6
- package/dist/workspace.js +198 -12
- package/package.json +1 -1
package/dist/cell.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
|
|
2
|
-
import {
|
|
1
|
+
import { ExecStartError, NotSupportedForModeError, ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
|
|
2
|
+
import { EXECUTION_ID, ExecutionResult, newExecutionId } from "./executions.js";
|
|
3
|
+
import { HttpClient, defaultSleep, randomId, treeRevisionOf } from "./http.js";
|
|
3
4
|
import { describeFailure, emitTo } from "./progress.js";
|
|
4
5
|
/** Fills a path template that must exist in cell-api.yaml. */
|
|
5
6
|
export function cellPath(template, params) {
|
|
@@ -73,6 +74,40 @@ class ByteSink {
|
|
|
73
74
|
return new TextDecoder().decode(Buffer.concat(this.#chunks));
|
|
74
75
|
}
|
|
75
76
|
}
|
|
77
|
+
const REVISION = /^[0-9a-f]{64}$/;
|
|
78
|
+
const revisionOf = (h) => {
|
|
79
|
+
const v = h.get('x-file-revision');
|
|
80
|
+
return v !== null && REVISION.test(v) ? v : null;
|
|
81
|
+
};
|
|
82
|
+
const servedFromOf = (h) => {
|
|
83
|
+
const v = h.get('x-served-from');
|
|
84
|
+
return v === 'disk' || v === 'guest' ? v : null;
|
|
85
|
+
};
|
|
86
|
+
async function parseJson(res, what) {
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(text);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new ShardfluxProtocolError(`${what}: response is not JSON`, res.status);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Delay before a retry of an execution: Retry-After (at most 30 s), else 0.5 s doubling to 8 s. */
|
|
96
|
+
const executionRetryDelayMs = (failures, retryAfterSeconds) => retryAfterSeconds !== undefined ? Math.min(30_000, retryAfterSeconds * 1000) : Math.min(8_000, 500 * 2 ** (failures - 1));
|
|
97
|
+
/** An attempt of `executions.run()` whose own wait ran out (AbortSignal.timeout), not the caller's signal. */
|
|
98
|
+
const attemptTimedOut = (err) => err instanceof Error && err.name === 'TimeoutError';
|
|
99
|
+
/** A transport failure or a retryable 429/5xx: the execution request may be sent again with the same id. */
|
|
100
|
+
function transientExecutionFailure(err) {
|
|
101
|
+
if (err instanceof ShardfluxApiError) {
|
|
102
|
+
const status = err.status === 429 || err.status === 502 || err.status === 503 || err.status === 504;
|
|
103
|
+
return status && err.retryable ? { retryAfterSeconds: err.retryAfterSeconds } : null;
|
|
104
|
+
}
|
|
105
|
+
// A proxy's error page (no error body): the gateway may never have seen the request.
|
|
106
|
+
if (err instanceof ShardfluxProtocolError)
|
|
107
|
+
return err.status === 502 || err.status === 503 || err.status === 504 ? { retryAfterSeconds: undefined } : null;
|
|
108
|
+
// fetch rejects with TypeError (connection refused/reset, DNS) or a DOMException; anything else is a bug: surface it.
|
|
109
|
+
return err instanceof TypeError || (err instanceof Error && err.name === 'NetworkError') ? { retryAfterSeconds: undefined } : null;
|
|
110
|
+
}
|
|
76
111
|
export class CellClient {
|
|
77
112
|
workspaceId;
|
|
78
113
|
tokens;
|
|
@@ -83,6 +118,9 @@ export class CellClient {
|
|
|
83
118
|
#transitionTimeoutMs;
|
|
84
119
|
#clients = new Map();
|
|
85
120
|
#closer = new AbortController();
|
|
121
|
+
#mode;
|
|
122
|
+
#onTreeRevision;
|
|
123
|
+
#treeRevision = null;
|
|
86
124
|
constructor(workspaceId, tokens, opts = {}) {
|
|
87
125
|
this.workspaceId = workspaceId;
|
|
88
126
|
this.tokens = tokens;
|
|
@@ -97,6 +135,49 @@ export class CellClient {
|
|
|
97
135
|
this.#transitionTimeoutMs = opts.transitionTimeoutMs ?? DEFAULT_TRANSITION_TIMEOUT_MS;
|
|
98
136
|
this.#listener = opts.onProgress;
|
|
99
137
|
this.#barrier = opts[CAPTURE_BARRIER];
|
|
138
|
+
this.#mode = opts.mode;
|
|
139
|
+
this.#onTreeRevision = opts.onTreeRevision;
|
|
140
|
+
}
|
|
141
|
+
/** The workspace's mode when known (CellClientOptions.mode), else undefined. */
|
|
142
|
+
get mode() {
|
|
143
|
+
return typeof this.#mode === 'function' ? this.#mode() : this.#mode;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The newest tree revision this client has seen (file-first workspaces: `X-Tree-Revision` of any response, execution
|
|
147
|
+
* results, `tree_revision_mismatch` refusals); null before the first. `workspace.treeRevision` combines every client's.
|
|
148
|
+
*/
|
|
149
|
+
get treeRevision() {
|
|
150
|
+
return this.#treeRevision;
|
|
151
|
+
}
|
|
152
|
+
#noteTreeRevision(rev) {
|
|
153
|
+
if (rev === null || rev === undefined || !Number.isSafeInteger(rev) || rev < 0)
|
|
154
|
+
return;
|
|
155
|
+
if (this.#treeRevision === null || rev > this.#treeRevision)
|
|
156
|
+
this.#treeRevision = rev;
|
|
157
|
+
this.#onTreeRevision?.(rev);
|
|
158
|
+
}
|
|
159
|
+
/** A call processful workspaces have: refused locally on a known file-first workspace. */
|
|
160
|
+
#processfulOnly(operation, instead) {
|
|
161
|
+
if (this.mode !== 'file_first')
|
|
162
|
+
return null;
|
|
163
|
+
return NotSupportedForModeError.local('file_first', operation, 'cell', `${operation} is not available for a file-first workspace: it has no VM between executions. ${instead}`);
|
|
164
|
+
}
|
|
165
|
+
/** A call only file-first workspaces have: refused locally on a known processful workspace. */
|
|
166
|
+
#fileFirstOnly(operation) {
|
|
167
|
+
if (this.mode !== 'processful')
|
|
168
|
+
return null;
|
|
169
|
+
return NotSupportedForModeError.local('processful', operation, 'cell', `${operation} is only available for file-first workspaces (open with mode 'file_first'); this workspace is processful.`);
|
|
170
|
+
}
|
|
171
|
+
/** `If-Match` for `ifTreeRevision`, or the local refusal on a processful workspace. */
|
|
172
|
+
#ifMatch(operation, opts) {
|
|
173
|
+
if (opts.ifTreeRevision === undefined)
|
|
174
|
+
return {};
|
|
175
|
+
if (!Number.isSafeInteger(opts.ifTreeRevision) || opts.ifTreeRevision < 0)
|
|
176
|
+
throw new TypeError('ifTreeRevision must be a non-negative integer');
|
|
177
|
+
const refusal = this.#fileFirstOnly(`${operation} with ifTreeRevision`);
|
|
178
|
+
if (refusal)
|
|
179
|
+
throw refusal;
|
|
180
|
+
return { 'if-match': String(opts.ifTreeRevision) };
|
|
100
181
|
}
|
|
101
182
|
#http(endpoint) {
|
|
102
183
|
let c = this.#clients.get(endpoint);
|
|
@@ -135,7 +216,7 @@ export class CellClient {
|
|
|
135
216
|
await barrier;
|
|
136
217
|
if (closer.aborted)
|
|
137
218
|
throw closer.reason;
|
|
138
|
-
const { wake: wakeAllowed = true, ...reqInit } = init;
|
|
219
|
+
const { wake: wakeAllowed = true, busy: busyAllowed = true, ...reqInit } = init;
|
|
139
220
|
const signal = reqInit.signal ? AbortSignal.any([reqInit.signal, closer]) : closer;
|
|
140
221
|
const deadline = Date.now() + this.#transitionTimeoutMs;
|
|
141
222
|
const t0 = performance.now();
|
|
@@ -150,9 +231,17 @@ export class CellClient {
|
|
|
150
231
|
for (;;) {
|
|
151
232
|
try {
|
|
152
233
|
const token = await this.tokens.get(listener);
|
|
153
|
-
|
|
234
|
+
const res = await this.#http(token.cell_endpoint).raw(method, path, { ...reqInit, signal, ...(emit ? { onRetry: (r) => retry(r.cause, r.delayMs, (attempts += 1)) } : {}) }, `Bearer ${token.token}`);
|
|
235
|
+
this.#noteTreeRevision(treeRevisionOf(res.headers));
|
|
236
|
+
return res;
|
|
154
237
|
}
|
|
155
238
|
catch (err) {
|
|
239
|
+
if (err instanceof ShardfluxApiError) {
|
|
240
|
+
this.#noteTreeRevision(err.treeRevision);
|
|
241
|
+
const cur = err.details?.current_tree_revision;
|
|
242
|
+
if (err.reason === 'tree_revision_mismatch' && typeof cur === 'number')
|
|
243
|
+
this.#noteTreeRevision(cur);
|
|
244
|
+
}
|
|
156
245
|
if (!(err instanceof ShardfluxApiError) || signal.aborted)
|
|
157
246
|
throw err;
|
|
158
247
|
if ((err.code === 'stale_epoch' || (err.status === 401 && err.source === 'cell')) && !refreshed) {
|
|
@@ -162,7 +251,7 @@ export class CellClient {
|
|
|
162
251
|
continue;
|
|
163
252
|
}
|
|
164
253
|
const left = deadline - Date.now();
|
|
165
|
-
if (err.code === 'workspace_busy' && left > 0) {
|
|
254
|
+
if (err.code === 'workspace_busy' && busyAllowed && left > 0) {
|
|
166
255
|
emit?.({ type: 'phase', phase: 'busy', reason: err.reason ?? 'workspace_busy' });
|
|
167
256
|
await this.#opts.sleep(Math.min(left, 5_000, Math.max(250, (err.retryAfterSeconds ?? 1) * 1000)));
|
|
168
257
|
continue;
|
|
@@ -170,9 +259,13 @@ export class CellClient {
|
|
|
170
259
|
const notRunning = err.code === 'workspace_not_running' || (err.code === 'conflict' && err.reason === 'workspace_not_running');
|
|
171
260
|
if (notRunning && wakeAllowed && this.#wake && wakes < MAX_WAKES && left > 0) {
|
|
172
261
|
wakes += 1;
|
|
262
|
+
const before = this.tokens.current;
|
|
173
263
|
if ((await this.#wake(left, signal)) === false)
|
|
174
264
|
throw err; // running per the API: nothing to wait for
|
|
175
|
-
this.
|
|
265
|
+
// A held resume (contracts §22.6) handed this client a token of the woken workspace: use it. Otherwise the
|
|
266
|
+
// old token is of the previous epoch: fetch a new one.
|
|
267
|
+
if (this.tokens.current === before)
|
|
268
|
+
this.tokens.invalidate();
|
|
176
269
|
refreshed = false;
|
|
177
270
|
continue;
|
|
178
271
|
}
|
|
@@ -199,13 +292,34 @@ export class CellClient {
|
|
|
199
292
|
#p(template, extra = {}) {
|
|
200
293
|
return cellPath(template, { workspace_id: this.workspaceId, ...extra });
|
|
201
294
|
}
|
|
295
|
+
/** The local refusal of an exec-session call on a file-first workspace (they run executions instead). */
|
|
296
|
+
#noSessions(operation) {
|
|
297
|
+
return this.#processfulOnly(operation, 'Run commands with executions.run(argv) (workspace.executions.run): each runs in a fresh VM on the latest tree and returns its output and the files it changed.');
|
|
298
|
+
}
|
|
202
299
|
// ---- exec --------------------------------------------------------------------------
|
|
300
|
+
/**
|
|
301
|
+
* Exec sessions of a processful workspace. On a file-first workspace these are refused (409 not_supported_for_mode;
|
|
302
|
+
* locally when the mode is known): use `executions.run()`.
|
|
303
|
+
*/
|
|
203
304
|
exec = {
|
|
204
305
|
/** Starts argv (no shell). Idempotent by session_id: an existing session is returned, never re-run. */
|
|
205
|
-
start: (req, signal) =>
|
|
206
|
-
|
|
306
|
+
start: (req, signal) => {
|
|
307
|
+
const refusal = this.#noSessions('exec.start');
|
|
308
|
+
if (refusal)
|
|
309
|
+
return Promise.reject(refusal);
|
|
310
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec'), { json: { session_id: randomId('x').replace(/-/g, '').slice(0, 32), ...req }, ...(signal ? { signal } : {}) });
|
|
311
|
+
},
|
|
312
|
+
get: (sessionId) => {
|
|
313
|
+
const refusal = this.#noSessions('exec.get');
|
|
314
|
+
if (refusal)
|
|
315
|
+
return Promise.reject(refusal);
|
|
316
|
+
return this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}', { session_id: sessionId }));
|
|
317
|
+
},
|
|
207
318
|
/** Output events from byte offsets (NDJSON). `follow` keeps the stream open until `exit`. */
|
|
208
319
|
output: async (sessionId, opts = {}) => {
|
|
320
|
+
const refusal = this.#noSessions('exec.output');
|
|
321
|
+
if (refusal)
|
|
322
|
+
throw refusal;
|
|
209
323
|
const res = await this.request('GET', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/output', { session_id: sessionId }), {
|
|
210
324
|
// Following never wakes: a workspace suspended under a running command stays suspended (explicit suspend).
|
|
211
325
|
wake: opts.follow === false,
|
|
@@ -218,13 +332,29 @@ export class CellClient {
|
|
|
218
332
|
throw new ShardfluxProtocolError('exec output: empty body', res.status);
|
|
219
333
|
return ndjson(res.body);
|
|
220
334
|
},
|
|
221
|
-
signal: (sessionId, signal, onlyLeader = false) =>
|
|
222
|
-
|
|
335
|
+
signal: (sessionId, signal, onlyLeader = false) => {
|
|
336
|
+
const refusal = this.#noSessions('exec.signal');
|
|
337
|
+
if (refusal)
|
|
338
|
+
return Promise.reject(refusal);
|
|
339
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/signal', { session_id: sessionId }), { json: { signal, only_leader: onlyLeader } });
|
|
340
|
+
},
|
|
341
|
+
cancel: (sessionId, graceMs) => {
|
|
342
|
+
const refusal = this.#noSessions('exec.cancel');
|
|
343
|
+
if (refusal)
|
|
344
|
+
return Promise.reject(refusal);
|
|
345
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/cancel', { session_id: sessionId }), { json: graceMs === undefined ? {} : { grace_ms: graceMs } });
|
|
346
|
+
},
|
|
223
347
|
/**
|
|
224
348
|
* Starts (or re-attaches to) a session and collects its output until it exits, reconnecting
|
|
225
349
|
* with offsets after dropped streams. Never issues a second start for the same session_id.
|
|
350
|
+
* A command that could not start (a cwd that is not a directory, a program not on PATH, an unknown user) rejects
|
|
351
|
+
* with ExecStartError (0.10.0+): nothing ran, so there is no exit code to return.
|
|
352
|
+
* A file-first workspace has no sessions: use `executions.run()` (refused locally when the mode is known).
|
|
226
353
|
*/
|
|
227
354
|
run: async (argv, opts = {}) => {
|
|
355
|
+
const refusal = this.#noSessions('exec.run');
|
|
356
|
+
if (refusal)
|
|
357
|
+
throw refusal;
|
|
228
358
|
const sessionId = opts.sessionId ?? `run-${randomId().replace(/-/g, '').slice(0, 24)}`;
|
|
229
359
|
const req = { session_id: sessionId, argv };
|
|
230
360
|
if (opts.cwd !== undefined)
|
|
@@ -241,7 +371,9 @@ export class CellClient {
|
|
|
241
371
|
req.kill_grace_ms = opts.killGraceMs;
|
|
242
372
|
if (opts.secretRefs !== undefined)
|
|
243
373
|
req.secret_refs = opts.secretRefs;
|
|
244
|
-
await this.exec.start(req, opts.signal);
|
|
374
|
+
const started = await this.exec.start(req, opts.signal);
|
|
375
|
+
if (started.state === 'failed_to_start')
|
|
376
|
+
throw new ExecStartError(started);
|
|
245
377
|
try {
|
|
246
378
|
return await this.#collect(sessionId, opts);
|
|
247
379
|
}
|
|
@@ -325,6 +457,9 @@ export class CellClient {
|
|
|
325
457
|
break;
|
|
326
458
|
}
|
|
327
459
|
}
|
|
460
|
+
// A start answered while the session was still starting can end without starting the command.
|
|
461
|
+
if (session.state === 'failed_to_start')
|
|
462
|
+
throw new ExecStartError(session);
|
|
328
463
|
return {
|
|
329
464
|
sessionId,
|
|
330
465
|
exitCode: session.exit_code ?? null,
|
|
@@ -340,15 +475,174 @@ export class CellClient {
|
|
|
340
475
|
reconnects,
|
|
341
476
|
};
|
|
342
477
|
}
|
|
478
|
+
// ---- executions (file-first workspaces, contracts §29.8) -----------------------------
|
|
479
|
+
executions = {
|
|
480
|
+
/**
|
|
481
|
+
* Runs argv (no shell; `['bash', '-lc', line]` for shell syntax) as an execution of this file-first workspace: a
|
|
482
|
+
* fresh VM on the latest tree revision, answered when the command ended. The files it changed under /home/user are
|
|
483
|
+
* the next revision (`treeRevision`, `changed`); processes, memory and files elsewhere do not survive it.
|
|
484
|
+
*
|
|
485
|
+
* The execution id (`executionId`, default a fresh `ex-<uuid>`) is the idempotency key: network failures and
|
|
486
|
+
* retryable 429/5xx answers (503 `no_execution_host`: no host has room, with Retry-After) are retried with the same
|
|
487
|
+
* id and request, at most `maxRetries` times, and an attempt that waits longer than `attemptTimeoutMs` re-attaches
|
|
488
|
+
* with the same id. The cell answers 201 for the call that ran the command and 200 (`replayed`) with the recorded
|
|
489
|
+
* result otherwise, so the command runs at most once. `workspace_busy` (`execution_in_progress`: another execution
|
|
490
|
+
* holds the workspace) is waited out within `transitionTimeoutMs`; `execution_id_reused` (the id with another
|
|
491
|
+
* request) and validation errors are thrown at once. The SDK never retries with a new id: a result whose `state` is
|
|
492
|
+
* `failed` or `lost` (nothing published; `error.details.reason` says why) is returned, and running it again is the
|
|
493
|
+
* caller's decision. Processful workspaces: NotSupportedForModeError (locally when the mode is known).
|
|
494
|
+
*/
|
|
495
|
+
run: async (argv, opts = {}) => {
|
|
496
|
+
const refusal = this.#fileFirstOnly('executions.run');
|
|
497
|
+
if (refusal)
|
|
498
|
+
throw refusal;
|
|
499
|
+
if (!Array.isArray(argv) || argv.length === 0)
|
|
500
|
+
throw new TypeError('argv must be a non-empty array of strings');
|
|
501
|
+
const executionId = opts.executionId ?? newExecutionId();
|
|
502
|
+
if (!EXECUTION_ID.test(executionId))
|
|
503
|
+
throw new TypeError(`executionId must match ${EXECUTION_ID.source}`);
|
|
504
|
+
const body = { argv, execution_id: executionId };
|
|
505
|
+
if (opts.cwd !== undefined)
|
|
506
|
+
body.cwd = opts.cwd;
|
|
507
|
+
if (opts.env !== undefined)
|
|
508
|
+
body.env = opts.env;
|
|
509
|
+
if (opts.user !== undefined)
|
|
510
|
+
body.user = opts.user;
|
|
511
|
+
if (opts.stdin !== undefined)
|
|
512
|
+
body.stdin = b64(opts.stdin);
|
|
513
|
+
if (opts.timeoutMs !== undefined)
|
|
514
|
+
body.timeout_ms = opts.timeoutMs;
|
|
515
|
+
if (opts.killGraceMs !== undefined)
|
|
516
|
+
body.kill_grace_ms = opts.killGraceMs;
|
|
517
|
+
if (opts.secretRefs !== undefined)
|
|
518
|
+
body.secret_refs = opts.secretRefs;
|
|
519
|
+
if (opts.outputLimitBytes !== undefined)
|
|
520
|
+
body.output_limit_bytes = opts.outputLimitBytes;
|
|
521
|
+
const path = this.#p('/v1/workspaces/{workspace_id}/exec');
|
|
522
|
+
const maxRetries = opts.maxRetries ?? 5;
|
|
523
|
+
const attemptTimeoutMs = opts.attemptTimeoutMs ?? 300_000;
|
|
524
|
+
const t0 = performance.now();
|
|
525
|
+
let failures = 0;
|
|
526
|
+
let attempt = 0;
|
|
527
|
+
for (;;) {
|
|
528
|
+
if (opts.signal?.aborted)
|
|
529
|
+
throw opts.signal.reason;
|
|
530
|
+
attempt += 1;
|
|
531
|
+
let res;
|
|
532
|
+
try {
|
|
533
|
+
// The same body every time: a different request with this id would be 409 execution_id_reused.
|
|
534
|
+
res = await this.request('POST', path, { json: body, timeoutMs: attemptTimeoutMs, ...(opts.signal ? { signal: opts.signal } : {}) });
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
if (opts.signal?.aborted || this.closed)
|
|
538
|
+
throw err;
|
|
539
|
+
if (attemptTimedOut(err)) {
|
|
540
|
+
this.#emitRetry(t0, `POST ${path}`, attempt, `no answer within ${attemptTimeoutMs} ms; re-attaching to execution ${executionId}`, 0);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const transient = transientExecutionFailure(err);
|
|
544
|
+
if (!transient || failures >= maxRetries)
|
|
545
|
+
throw err;
|
|
546
|
+
failures += 1;
|
|
547
|
+
const delayMs = executionRetryDelayMs(failures, transient.retryAfterSeconds);
|
|
548
|
+
this.#emitRetry(t0, `POST ${path}`, attempt, `${describeFailure(err)} (same execution id ${executionId})`, delayMs);
|
|
549
|
+
await this.#opts.sleep(delayMs);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const result = new ExecutionResult(await parseJson(res, `POST ${path}`), res.status !== 201);
|
|
553
|
+
this.#noteTreeRevision(result.treeRevision);
|
|
554
|
+
// The cell answers when the command ended; should it ever answer earlier, follow the execution to its end.
|
|
555
|
+
return result.pending ? this.#followExecution(executionId, opts.signal) : result;
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
/**
|
|
559
|
+
* The execution `executionId` of this file-first workspace (`GET /executions/{id}`): its result once it ended, or a
|
|
560
|
+
* pending result (`pending`, state `queued`/`running`, no output yet) while it runs; with `waitMs`, polls until it
|
|
561
|
+
* ended or the wait elapsed. Results are kept 7 days; an unknown id is 404 `not_found`. `replayed` is true.
|
|
562
|
+
*/
|
|
563
|
+
get: async (executionId, opts = {}) => {
|
|
564
|
+
const refusal = this.#fileFirstOnly('executions.get');
|
|
565
|
+
if (refusal)
|
|
566
|
+
throw refusal;
|
|
567
|
+
if (!EXECUTION_ID.test(executionId))
|
|
568
|
+
throw new TypeError(`executionId must match ${EXECUTION_ID.source}`);
|
|
569
|
+
const path = this.#p('/v1/workspaces/{workspace_id}/executions/{execution_id}', { execution_id: executionId });
|
|
570
|
+
const deadline = Date.now() + Math.max(0, opts.waitMs ?? 0);
|
|
571
|
+
let budget = Math.max(0, opts.waitMs ?? 0);
|
|
572
|
+
let delay = 250;
|
|
573
|
+
for (;;) {
|
|
574
|
+
const res = await this.request('GET', path, opts.signal ? { signal: opts.signal } : {});
|
|
575
|
+
const result = new ExecutionResult(await parseJson(res, `GET ${path}`), true);
|
|
576
|
+
this.#noteTreeRevision(result.treeRevision);
|
|
577
|
+
const left = Math.min(budget, deadline - Date.now());
|
|
578
|
+
if (!result.pending || left <= 0)
|
|
579
|
+
return result;
|
|
580
|
+
const d = Math.min(delay, left);
|
|
581
|
+
await this.#opts.sleep(d);
|
|
582
|
+
budget -= d;
|
|
583
|
+
delay = Math.min(2_000, delay * 2);
|
|
584
|
+
if (opts.signal?.aborted)
|
|
585
|
+
throw opts.signal.reason;
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
/** Polls an execution until it ended (only when the cell answered a run before the command ended). */
|
|
590
|
+
async #followExecution(executionId, signal) {
|
|
591
|
+
for (;;) {
|
|
592
|
+
const r = await this.executions.get(executionId, { waitMs: 60_000, ...(signal ? { signal } : {}) });
|
|
593
|
+
if (!r.pending)
|
|
594
|
+
return r;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
#emitRetry(t0, request, attempt, cause, delayMs) {
|
|
598
|
+
const listener = this.#listener;
|
|
599
|
+
if (!listener)
|
|
600
|
+
return;
|
|
601
|
+
const atMs = Math.round((performance.now() - t0) * 10) / 10;
|
|
602
|
+
emitTo([listener], { action: 'tool', workspaceId: this.workspaceId, operationId: null, atMs, type: 'retry', retry: { atMs, request, attempt, cause, delayMs } });
|
|
603
|
+
}
|
|
604
|
+
/** The local refusal of a call that needs the workspace's VM (PTY, processes, version control, browser, changes). */
|
|
605
|
+
#needsVm(operation) {
|
|
606
|
+
return this.#processfulOnly(operation, 'Processes, terminals and sessions live within one execution only: run the command with executions.run().');
|
|
607
|
+
}
|
|
343
608
|
// ---- PTY ---------------------------------------------------------------------------
|
|
609
|
+
/** PTY sessions of a processful workspace (file-first: NotSupportedForModeError). */
|
|
344
610
|
pty = {
|
|
345
|
-
open: (req = {}) =>
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
611
|
+
open: (req = {}) => {
|
|
612
|
+
const refusal = this.#needsVm('pty.open');
|
|
613
|
+
if (refusal)
|
|
614
|
+
return Promise.reject(refusal);
|
|
615
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/pty'), { json: { session_id: `pty-${randomId().replace(/-/g, '').slice(0, 24)}`, ...req } });
|
|
616
|
+
},
|
|
617
|
+
get: (sessionId) => {
|
|
618
|
+
const refusal = this.#needsVm('pty.get');
|
|
619
|
+
if (refusal)
|
|
620
|
+
return Promise.reject(refusal);
|
|
621
|
+
return this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}', { session_id: sessionId }));
|
|
622
|
+
},
|
|
623
|
+
close: (sessionId) => {
|
|
624
|
+
const refusal = this.#needsVm('pty.close');
|
|
625
|
+
if (refusal)
|
|
626
|
+
return Promise.reject(refusal);
|
|
627
|
+
return this.#json('DELETE', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}', { session_id: sessionId }));
|
|
628
|
+
},
|
|
629
|
+
resize: (sessionId, rows, cols) => {
|
|
630
|
+
const refusal = this.#needsVm('pty.resize');
|
|
631
|
+
if (refusal)
|
|
632
|
+
return Promise.reject(refusal);
|
|
633
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}/resize', { session_id: sessionId }), { json: { rows, cols } });
|
|
634
|
+
},
|
|
635
|
+
input: (sessionId, data) => {
|
|
636
|
+
const refusal = this.#needsVm('pty.input');
|
|
637
|
+
if (refusal)
|
|
638
|
+
return Promise.reject(refusal);
|
|
639
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}/input', { session_id: sessionId }), { json: { data: b64(data) } });
|
|
640
|
+
},
|
|
350
641
|
/** wss:// URL of the attach WebSocket (send the tool token as `Authorization: Bearer`). */
|
|
351
642
|
attachUrl: async (sessionId, offset = 0) => {
|
|
643
|
+
const refusal = this.#needsVm('pty.attach');
|
|
644
|
+
if (refusal)
|
|
645
|
+
throw refusal;
|
|
352
646
|
const token = await this.tokens.get();
|
|
353
647
|
const u = new URL(token.cell_endpoint + this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}/attach', { session_id: sessionId }));
|
|
354
648
|
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
@@ -426,8 +720,16 @@ export class CellClient {
|
|
|
426
720
|
};
|
|
427
721
|
// ---- processes -----------------------------------------------------------------------
|
|
428
722
|
processes = {
|
|
429
|
-
list: () =>
|
|
723
|
+
list: () => {
|
|
724
|
+
const refusal = this.#needsVm('process.list');
|
|
725
|
+
if (refusal)
|
|
726
|
+
return Promise.reject(refusal);
|
|
727
|
+
return this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/processes'));
|
|
728
|
+
},
|
|
430
729
|
signal: async (pid, signal) => {
|
|
730
|
+
const refusal = this.#needsVm('process.signal');
|
|
731
|
+
if (refusal)
|
|
732
|
+
throw refusal;
|
|
431
733
|
await this.request('POST', this.#p('/v1/workspaces/{workspace_id}/processes/{pid}/signal', { pid }), { json: { signal } });
|
|
432
734
|
},
|
|
433
735
|
};
|
|
@@ -438,20 +740,31 @@ export class CellClient {
|
|
|
438
740
|
* (FILE_MAX_READ_BYTES) and reports the size at read start in `X-File-Size`, so shorter reads
|
|
439
741
|
* are continued from the next offset until that size (a file that shrinks ends the loop early).
|
|
440
742
|
*/
|
|
441
|
-
read: async (path, opts = {}) =>
|
|
743
|
+
read: async (path, opts = {}) => (await this.files.readWithInfo(path, opts)).data,
|
|
744
|
+
/**
|
|
745
|
+
* Like `read()`, plus the file's size, `revision` (SHA-256 of the whole file, `X-File-Revision`, for regular files of
|
|
746
|
+
* 16 MiB or less; pass it as `expectedRevision` to `patch()`) and `servedFrom` (`disk` when a sleeping workspace was
|
|
747
|
+
* read from its disk without waking it, contracts §26.4). 0.9.0+.
|
|
748
|
+
*/
|
|
749
|
+
readWithInfo: async (path, opts = {}) => {
|
|
442
750
|
const url = this.#p('/v1/workspaces/{workspace_id}/files');
|
|
443
751
|
const first = await this.request('GET', url, { query: { path, offset: opts.offset, length: opts.length }, accept: 'application/octet-stream' });
|
|
444
752
|
const head = new Uint8Array(await first.arrayBuffer());
|
|
445
753
|
const sizeHeader = first.headers.get('x-file-size');
|
|
446
754
|
const size = sizeHeader !== null && /^\d+$/.test(sizeHeader) ? Number(sizeHeader) : null;
|
|
755
|
+
const servedFrom = servedFromOf(first.headers);
|
|
756
|
+
let revision = revisionOf(first.headers);
|
|
447
757
|
const start = opts.offset ?? 0;
|
|
448
758
|
if (opts.length !== undefined || size === null || start + head.byteLength >= size || head.byteLength === 0)
|
|
449
|
-
return head;
|
|
759
|
+
return { data: head, size, revision, servedFrom };
|
|
450
760
|
const parts = [head];
|
|
451
761
|
let offset = start + head.byteLength;
|
|
452
762
|
while (offset < size) {
|
|
453
763
|
const res = await this.request('GET', url, { query: { path, offset }, accept: 'application/octet-stream' });
|
|
454
764
|
const chunk = new Uint8Array(await res.arrayBuffer());
|
|
765
|
+
// The revision describes the bytes returned only when every part was read from that same content.
|
|
766
|
+
if (revisionOf(res.headers) !== revision)
|
|
767
|
+
revision = null;
|
|
455
768
|
if (chunk.byteLength === 0)
|
|
456
769
|
break;
|
|
457
770
|
parts.push(chunk);
|
|
@@ -463,27 +776,114 @@ export class CellClient {
|
|
|
463
776
|
out.set(p, at);
|
|
464
777
|
at += p.byteLength;
|
|
465
778
|
}
|
|
466
|
-
return out;
|
|
779
|
+
return { data: out, size, revision, servedFrom };
|
|
467
780
|
},
|
|
468
781
|
readText: async (path, opts = {}) => new TextDecoder().decode(await this.files.read(path, opts)),
|
|
469
|
-
/**
|
|
470
|
-
|
|
782
|
+
/**
|
|
783
|
+
* Atomic replace (or append), acknowledged after fsync of file and parent directory (file-first: once the new tree
|
|
784
|
+
* revision is published). `ifTreeRevision` (file-first) applies it only at that tree revision.
|
|
785
|
+
*/
|
|
786
|
+
write: async (path, data, opts = {}) => this.#json('PUT', this.#p('/v1/workspaces/{workspace_id}/files'), {
|
|
471
787
|
query: { path, mode: opts.mode, create_parents: opts.createParents, append: opts.append },
|
|
472
788
|
body: typeof data === 'string' ? new TextEncoder().encode(data) : data,
|
|
473
789
|
contentType: 'application/octet-stream',
|
|
474
790
|
idempotencyKey: opts.idempotencyKey ?? randomId('w-').slice(0, 40),
|
|
791
|
+
headers: this.#ifMatch('files.write', opts),
|
|
475
792
|
}),
|
|
476
793
|
remove: async (path, opts = {}) => {
|
|
477
|
-
await this.request('DELETE', this.#p('/v1/workspaces/{workspace_id}/files'), { query: { path, recursive: opts.recursive } });
|
|
794
|
+
await this.request('DELETE', this.#p('/v1/workspaces/{workspace_id}/files'), { query: { path, recursive: opts.recursive }, headers: this.#ifMatch('files.remove', opts) });
|
|
478
795
|
},
|
|
479
|
-
|
|
796
|
+
/** `revision: true` (0.9.0+) adds `revision`, the SHA-256 of a regular file's content (files of up to 256 MiB). */
|
|
797
|
+
stat: (path, opts = {}) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/files/stat'), { query: { path, revision: opts.revision } }),
|
|
480
798
|
list: (path, opts = {}) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/files/list'), { query: { path, limit: opts.limit } }),
|
|
481
|
-
mkdir: (path, opts = {}) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/files/mkdir'), {
|
|
482
|
-
|
|
799
|
+
mkdir: async (path, opts = {}) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/files/mkdir'), {
|
|
800
|
+
json: { path, ...(opts.parents !== undefined ? { parents: opts.parents } : {}), ...(opts.mode !== undefined ? { mode: opts.mode } : {}) },
|
|
801
|
+
headers: this.#ifMatch('files.mkdir', opts),
|
|
802
|
+
}),
|
|
803
|
+
move: async (from, to, opts = {}) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/files/move'), {
|
|
804
|
+
json: { from, to, ...(opts.overwrite !== undefined ? { overwrite: opts.overwrite } : {}) },
|
|
805
|
+
headers: this.#ifMatch('files.move', opts),
|
|
806
|
+
}),
|
|
807
|
+
/**
|
|
808
|
+
* Searches file contents under `path`, a directory or one file (contracts §26.1; 0.9.0+): matching lines in path
|
|
809
|
+
* order, bounded by `maxMatches`, a 10 s budget and 4 MiB of results (`truncated`, `stop_reason` `max_matches`,
|
|
810
|
+
* `budget` or `max_bytes`). Binary files, symbolic links, special files and files above `maxFileBytes` are
|
|
811
|
+
* skipped. Read-only, so transient failures (e.g. 503 `host_capacity`) are retried; a suspended workspace whose
|
|
812
|
+
* disk a host still holds is searched there without waking it (`served_from: 'disk'`).
|
|
813
|
+
*/
|
|
814
|
+
search: async (path, pattern, opts = {}) => {
|
|
815
|
+
const body = { path, pattern };
|
|
816
|
+
if (opts.regex !== undefined)
|
|
817
|
+
body.regex = opts.regex;
|
|
818
|
+
if (opts.caseInsensitive !== undefined)
|
|
819
|
+
body.case_insensitive = opts.caseInsensitive;
|
|
820
|
+
if (opts.include !== undefined)
|
|
821
|
+
body.include = opts.include;
|
|
822
|
+
if (opts.exclude !== undefined)
|
|
823
|
+
body.exclude = opts.exclude;
|
|
824
|
+
if (opts.maxMatches !== undefined)
|
|
825
|
+
body.max_matches = opts.maxMatches;
|
|
826
|
+
if (opts.maxFileBytes !== undefined)
|
|
827
|
+
body.max_file_bytes = opts.maxFileBytes;
|
|
828
|
+
if (opts.contextLines !== undefined)
|
|
829
|
+
body.context_lines = opts.contextLines;
|
|
830
|
+
const url = this.#p('/v1/workspaces/{workspace_id}/files/search');
|
|
831
|
+
const res = await this.request('POST', url, { json: body, idempotent: true, ...(opts.signal ? { signal: opts.signal } : {}) });
|
|
832
|
+
return { ...(await parseJson(res, `POST ${url}`)), served_from: servedFromOf(res.headers) };
|
|
833
|
+
},
|
|
834
|
+
/**
|
|
835
|
+
* Applies text `edits` (each `oldText` must occur exactly once unless `replaceAll`; in order) or replaces the whole
|
|
836
|
+
* file with `content`, atomically and durably (contracts §26.2; 0.9.0+). With `expectedRevision` the file must
|
|
837
|
+
* still have that revision (`absent`: must not exist), else 409 `conflict` `revision_mismatch` with
|
|
838
|
+
* `details.current_revision`. Edits that do not apply: 422 `validation_failed` `edit_not_found` / `edit_ambiguous`
|
|
839
|
+
* (`details.index`), `edit_not_text`. Limits: a request of up to 7 MiB (else 413 `payload_too_large`; larger
|
|
840
|
+
* content goes through `write()`), files of up to 64 MiB, no symlink targets. Always sends an Idempotency-Key, so
|
|
841
|
+
* a retried call is applied once (a replay returns the recorded result, refusals included). `ifTreeRevision`
|
|
842
|
+
* (file-first) applies it only at that tree revision.
|
|
843
|
+
*/
|
|
844
|
+
patch: async (params, opts = {}) => {
|
|
845
|
+
if ((params.edits === undefined) === (params.content === undefined))
|
|
846
|
+
throw new TypeError('files.patch needs exactly one of edits or content');
|
|
847
|
+
const headers = this.#ifMatch('files.patch', opts);
|
|
848
|
+
const body = { path: params.path };
|
|
849
|
+
if (params.edits !== undefined)
|
|
850
|
+
body.edits = params.edits.map((e) => ({ old_text: e.oldText, new_text: e.newText, ...(e.replaceAll !== undefined ? { replace_all: e.replaceAll } : {}) }));
|
|
851
|
+
if (params.content !== undefined)
|
|
852
|
+
body.content = params.content;
|
|
853
|
+
if (params.expectedRevision !== undefined)
|
|
854
|
+
body.expected_revision = params.expectedRevision;
|
|
855
|
+
if (params.createParents !== undefined)
|
|
856
|
+
body.create_parents = params.createParents;
|
|
857
|
+
if (params.mode !== undefined)
|
|
858
|
+
body.mode = params.mode;
|
|
859
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/files/patch'), {
|
|
860
|
+
json: body,
|
|
861
|
+
idempotencyKey: opts.idempotencyKey ?? randomId('p-').slice(0, 40),
|
|
862
|
+
headers,
|
|
863
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
864
|
+
});
|
|
865
|
+
},
|
|
483
866
|
};
|
|
867
|
+
/**
|
|
868
|
+
* `POST /wake-hint` (contracts §26.6; 0.9.0+): announces an imminent tool call so a hibernated workspace is restored
|
|
869
|
+
* ahead of it. It never wakes a suspended workspace, never waits out `workspace_busy` and is not retried: a suspended
|
|
870
|
+
* workspace answers 409 `workspace_not_running` (`Workspace.hint()` then wakes it in the background).
|
|
871
|
+
*/
|
|
872
|
+
wakeHint(signal) {
|
|
873
|
+
// Nothing of a file-first workspace sleeps: the cell would answer `resident` (contracts §29.8).
|
|
874
|
+
if (this.mode === 'file_first')
|
|
875
|
+
return Promise.resolve({ residency: 'resident' });
|
|
876
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/wake-hint'), { wake: false, busy: false, timeoutMs: 10_000, ...(signal ? { signal } : {}) });
|
|
877
|
+
}
|
|
484
878
|
// ---- changes against the template (layered workspaces, contracts §19.10) ------------------
|
|
485
|
-
/**
|
|
879
|
+
/**
|
|
880
|
+
* One page of the workspace's changes against its template (needs the `files` tool). File-first workspaces: each
|
|
881
|
+
* execution's result lists what it changed (`changed`); this route is refused (NotSupportedForModeError).
|
|
882
|
+
*/
|
|
486
883
|
changes(params = {}) {
|
|
884
|
+
const refusal = this.#processfulOnly('files.changes', 'Each execution result lists what the command changed (changed).');
|
|
885
|
+
if (refusal)
|
|
886
|
+
return Promise.reject(refusal);
|
|
487
887
|
return this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/changes'), {
|
|
488
888
|
query: { path_prefix: params.pathPrefix, limit: params.limit, cursor: params.cursor, hash: params.hash, summary: params.summary },
|
|
489
889
|
});
|
|
@@ -499,13 +899,38 @@ export class CellClient {
|
|
|
499
899
|
}
|
|
500
900
|
// ---- git -------------------------------------------------------------------------------
|
|
501
901
|
git = {
|
|
502
|
-
clone: (req) =>
|
|
503
|
-
|
|
504
|
-
|
|
902
|
+
clone: (req) => {
|
|
903
|
+
const refusal = this.#needsVm('git.clone');
|
|
904
|
+
if (refusal)
|
|
905
|
+
return Promise.reject(refusal);
|
|
906
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/git/clone'), { json: req, timeoutMs: (req.timeout_ms ?? 600_000) + 30_000 });
|
|
907
|
+
},
|
|
908
|
+
status: (path) => {
|
|
909
|
+
const refusal = this.#needsVm('git.status');
|
|
910
|
+
if (refusal)
|
|
911
|
+
return Promise.reject(refusal);
|
|
912
|
+
return this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/git/status'), { query: { path } });
|
|
913
|
+
},
|
|
914
|
+
commit: (req) => {
|
|
915
|
+
const refusal = this.#needsVm('git.commit');
|
|
916
|
+
if (refusal)
|
|
917
|
+
return Promise.reject(refusal);
|
|
918
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/git/commit'), { json: req });
|
|
919
|
+
},
|
|
505
920
|
};
|
|
506
921
|
// ---- browser ---------------------------------------------------------------------------
|
|
507
922
|
browser = {
|
|
508
|
-
screenshot: (req) =>
|
|
509
|
-
|
|
923
|
+
screenshot: (req) => {
|
|
924
|
+
const refusal = this.#needsVm('browser.screenshot');
|
|
925
|
+
if (refusal)
|
|
926
|
+
return Promise.reject(refusal);
|
|
927
|
+
return this.#bytes('POST', this.#p('/v1/workspaces/{workspace_id}/browser/screenshot'), { json: req, accept: 'image/png', timeoutMs: (req.timeout_ms ?? 30_000) + 15_000 });
|
|
928
|
+
},
|
|
929
|
+
content: (req) => {
|
|
930
|
+
const refusal = this.#needsVm('browser.content');
|
|
931
|
+
if (refusal)
|
|
932
|
+
return Promise.reject(refusal);
|
|
933
|
+
return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/browser/content'), { json: req, timeoutMs: (req.timeout_ms ?? 30_000) + 15_000 });
|
|
934
|
+
},
|
|
510
935
|
};
|
|
511
936
|
}
|