@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/dist/cell.js CHANGED
@@ -1,5 +1,6 @@
1
- import { ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
2
- import { HttpClient, defaultSleep, randomId } from "./http.js";
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
- return await this.#http(token.cell_endpoint).raw(method, path, { ...reqInit, signal, ...(emit ? { onRetry: (r) => retry(r.cause, r.delayMs, (attempts += 1)) } : {}) }, `Bearer ${token.token}`);
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.tokens.invalidate();
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) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec'), { json: { session_id: randomId('x').replace(/-/g, '').slice(0, 32), ...req }, ...(signal ? { signal } : {}) }),
206
- get: (sessionId) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}', { session_id: sessionId })),
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) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/signal', { session_id: sessionId }), { json: { signal, only_leader: onlyLeader } }),
222
- cancel: (sessionId, graceMs) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/cancel', { session_id: sessionId }), { json: graceMs === undefined ? {} : { grace_ms: graceMs } }),
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 = {}) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/pty'), { json: { session_id: `pty-${randomId().replace(/-/g, '').slice(0, 24)}`, ...req } }),
346
- get: (sessionId) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}', { session_id: sessionId })),
347
- close: (sessionId) => this.#json('DELETE', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}', { session_id: sessionId })),
348
- resize: (sessionId, rows, cols) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}/resize', { session_id: sessionId }), { json: { rows, cols } }),
349
- input: (sessionId, data) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}/input', { session_id: sessionId }), { json: { data: b64(data) } }),
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: () => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/processes')),
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
- /** Atomic replace (or append), acknowledged after fsync of file and parent directory. */
470
- write: (path, data, opts = {}) => this.#json('PUT', this.#p('/v1/workspaces/{workspace_id}/files'), {
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
- stat: (path) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/files/stat'), { query: { path } }),
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'), { json: { path, ...(opts.parents !== undefined ? { parents: opts.parents } : {}), ...(opts.mode !== undefined ? { mode: opts.mode } : {}) } }),
482
- move: (from, to, opts = {}) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/files/move'), { json: { from, to, ...(opts.overwrite !== undefined ? { overwrite: opts.overwrite } : {}) } }),
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
- /** One page of the workspace's changes against its template (needs the `files` tool). */
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) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/git/clone'), { json: req, timeoutMs: (req.timeout_ms ?? 600_000) + 30_000 }),
503
- status: (path) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/git/status'), { query: { path } }),
504
- commit: (req) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/git/commit'), { json: req }),
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) => this.#bytes('POST', this.#p('/v1/workspaces/{workspace_id}/browser/screenshot'), { json: req, accept: 'image/png', timeoutMs: (req.timeout_ms ?? 30_000) + 15_000 }),
509
- content: (req) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/browser/content'), { json: req, timeoutMs: (req.timeout_ms ?? 30_000) + 15_000 }),
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
  }