@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/workspace.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { CAPTURE_BARRIER, CellClient, DEFAULT_TRANSITION_TIMEOUT_MS } from "./cell.js";
2
2
  import { ToolCallCapture } from "./capture.js";
3
- import { OperationFailedError, ShardfluxApiError } from "./errors.js";
4
- import { defaultSleep, randomId } from "./http.js";
5
- import { AFTER_WAIT, TRACE } from "./lifecycle.js";
3
+ import { NotSupportedForModeError, OperationFailedError, ShardfluxApiError } from "./errors.js";
4
+ import { SERVER_WAIT_MAX_S, defaultSleep, randomId } from "./http.js";
5
+ import { AFTER_WAIT, HELD_RESUME, TRACE } from "./lifecycle.js";
6
6
  import { Trace, combineListeners, traced } from "./progress.js";
7
7
  import { WorkspaceSecrets } from "./secrets.js";
8
8
  import { ToolTokenManager } from "./tokens.js";
9
+ const notRunning = (e) => e instanceof ShardfluxApiError && (e.code === 'workspace_not_running' || (e.code === 'conflict' && e.reason === 'workspace_not_running'));
9
10
  export class Workspace {
10
11
  #view;
12
+ /** The background wake started by hint(), shared by concurrent hints until it settles. */
13
+ #hintWake = null;
11
14
  #ctx;
12
15
  #defaults;
13
16
  #managers = new Map();
@@ -15,14 +18,27 @@ export class Workspace {
15
18
  /** The open() that produced this handle; its timing is final once open() has returned. */
16
19
  #openTrace;
17
20
  #lastTiming;
21
+ /** The newest tree revision seen (file-first): the view's, or any cell response's since. */
22
+ #treeRevision;
18
23
  constructor(ctx, view, opts = {}) {
19
24
  this.#ctx = ctx;
20
25
  this.#view = view;
26
+ this.#treeRevision = view.mode === 'file_first' && typeof view.tree_revision === 'number' ? view.tree_revision : null;
21
27
  this.#defaults = { agentLabel: opts.agentLabel, tools: opts.tools };
22
28
  this.#openTrace = opts.trace;
23
29
  if (opts.token)
24
30
  this.tokens().seed(opts.token);
25
31
  }
32
+ #noteTreeRevision(rev) {
33
+ if (this.#treeRevision === null || rev > this.#treeRevision)
34
+ this.#treeRevision = rev;
35
+ }
36
+ /** The local refusal of a lifecycle call a file-first workspace does not have (contracts §29.7). */
37
+ #needsVm(operation) {
38
+ if (this.#view.mode !== 'file_first')
39
+ return null;
40
+ return NotSupportedForModeError.local('file_first', operation, 'api', `${operation} is not available for a file-first workspace: it has no VM between executions and is never suspended; its state is its file tree (workspace.treeRevision).`);
41
+ }
26
42
  /**
27
43
  * Where the time went in the last lifecycle call made through this handle: open(), wake() (also when a tool call
28
44
  * woke the workspace), or a lifecycle call with `wait`. Null for handles from get()/list() until such a call.
@@ -116,10 +132,45 @@ export class Workspace {
116
132
  get startup() {
117
133
  return this.#view.startup ?? null;
118
134
  }
135
+ /**
136
+ * The pending suspend-when-idle request as of the last view (0.10.0; `idle.suspend_request`): `{requested_at,
137
+ * after_seconds, not_before}`, or null when there is none, when the workspace is not running, or once a tool call or
138
+ * a resume after the request cancelled it. `refresh()` reads it again.
139
+ */
140
+ get suspendRequest() {
141
+ return this.#view.idle?.suspend_request ?? null;
142
+ }
119
143
  /** The raw view (GET /v1/workspaces/{id}). */
120
144
  get data() {
121
145
  return this.#view;
122
146
  }
147
+ /**
148
+ * `processful` (one VM keeps processes, memory and files) or `file_first` (0.9.0; contracts §29: a versioned file
149
+ * tree, commands run as executions). Immutable. A view without the field (older API) is processful.
150
+ */
151
+ get mode() {
152
+ return this.#view.mode ?? 'processful';
153
+ }
154
+ /**
155
+ * File-first workspaces: the newest tree revision this handle has seen (0 when created; each mutating files call or
156
+ * execution that changed something publishes the next). It follows every response of this handle's cell clients
157
+ * (`X-Tree-Revision`), execution results and refresh(); another writer's changes appear once a response reports
158
+ * them. Pass it as `ifTreeRevision` to make a write conditional. Null for processful workspaces.
159
+ */
160
+ get treeRevision() {
161
+ return this.mode === 'file_first' ? this.#treeRevision : null;
162
+ }
163
+ /**
164
+ * Executions of this file-first workspace (contracts §29.8) through `cell()`'s default client: `run(argv, opts)` runs
165
+ * a command in a fresh VM on the latest tree and returns an ExecutionResult (output, exit code, `changed`,
166
+ * `treeRevision`); `get(id, { waitMs })` reads one. See CellClient.executions.
167
+ *
168
+ * const r = await workspace.executions.run(['bash', '-lc', 'pytest -q'], { timeoutMs: 600_000 });
169
+ * console.log(r.exitCode, r.stdoutText, r.changed, r.treeRevision);
170
+ */
171
+ get executions() {
172
+ return this.cell().executions;
173
+ }
123
174
  /** Secret names bound to this workspace (injected into every exec/PTY start): `get()`, `set(names)`. */
124
175
  get secrets() {
125
176
  return new WorkspaceSecrets(this.#ctx, this.id);
@@ -130,6 +181,8 @@ export class Workspace {
130
181
  }
131
182
  async refresh() {
132
183
  this.#view = await this.#ctx.http.json('GET', `/v1/workspaces/${encodeURIComponent(this.id)}`, {}, this.#ctx.authorization);
184
+ if (this.#view.mode === 'file_first' && typeof this.#view.tree_revision === 'number')
185
+ this.#noteTreeRevision(this.#view.tree_revision);
133
186
  return this;
134
187
  }
135
188
  /** Waits for the active operation (if any) and refreshes. */
@@ -143,15 +196,67 @@ export class Workspace {
143
196
  return this.#ctx.workspaces.delete(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait() });
144
197
  }
145
198
  suspend(opts = {}) {
199
+ const refusal = this.#needsVm('suspend');
200
+ if (refusal)
201
+ return Promise.reject(refusal);
146
202
  return this.#ctx.workspaces.suspend(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait() });
147
203
  }
204
+ /**
205
+ * Suspends the workspace once it has been idle for `afterSeconds` (30..3600; 0.9.0): call it when your agent's turn
206
+ * ends, so the workspace stops using RAM soon after instead of waiting out its idle policy. A command still running,
207
+ * an attached stream or a keepalive postpones the suspend until `afterSeconds` after it ends; the next tool call (the
208
+ * next turn) or a resume cancels it. Resolves with the recorded `suspendRequest` (also `workspace.suspendRequest`), or
209
+ * with the suspend already in progress as `operation`. See WorkspacesApi.suspendWhenIdle for the errors. A file-first
210
+ * workspace (never suspended) is refused locally with NotSupportedForModeError.
211
+ *
212
+ * const { suspendRequest } = await workspace.suspendWhenIdle({ afterSeconds: 60 });
213
+ */
214
+ async suspendWhenIdle(opts) {
215
+ const refusal = this.#needsVm('suspend_when_idle');
216
+ if (refusal)
217
+ throw refusal;
218
+ const out = await this.#ctx.workspaces.suspendWhenIdle(this.id, opts);
219
+ this.#view = out.workspace.data;
220
+ return { ...out, workspace: this };
221
+ }
222
+ /**
223
+ * Cancels a pending suspend-when-idle request (0.10.0; idempotent, in any state) and refreshes this handle's view.
224
+ * A suspend the request already started is not undone (it shows as `activeOperation`).
225
+ */
226
+ async cancelSuspendWhenIdle() {
227
+ this.#view = (await this.#ctx.workspaces.cancelSuspendWhenIdle(this.id)).data;
228
+ return this;
229
+ }
148
230
  resume(opts = {}) {
149
- return this.#ctx.workspaces.resume(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait() });
231
+ const refusal = this.#needsVm('resume');
232
+ if (refusal)
233
+ return Promise.reject(refusal);
234
+ // With `wait` the held resume's view and token go straight into this handle (the token for agentLabel/tools).
235
+ const manager = this.tokens({ ...(opts.agentLabel !== undefined ? { agentLabel: opts.agentLabel } : {}), ...(opts.tools !== undefined ? { tools: opts.tools } : {}) });
236
+ return this.#ctx.workspaces.resume(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait(), [HELD_RESUME]: this.#heldTarget(manager) });
237
+ }
238
+ /** A held resume's 200 (contracts §22.6) goes straight into this handle: the view, and the token for `manager`. */
239
+ #heldTarget(manager) {
240
+ return {
241
+ agentLabel: manager.agentLabel,
242
+ tools: manager.tools,
243
+ adopt: (view, token) => {
244
+ this.#view = view;
245
+ if (token)
246
+ manager.seed(token);
247
+ },
248
+ };
150
249
  }
151
250
  snapshot(opts = {}) {
251
+ const refusal = this.#needsVm('snapshot');
252
+ if (refusal)
253
+ return Promise.reject(refusal);
152
254
  return this.#ctx.workspaces.snapshot(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait() });
153
255
  }
154
256
  fork(target, opts = {}) {
257
+ const refusal = this.#needsVm('fork');
258
+ if (refusal)
259
+ return Promise.reject(refusal);
155
260
  return this.#ctx.workspaces.fork(this.id, target, this.#tracked(opts));
156
261
  }
157
262
  async close(opts = {}) {
@@ -169,6 +274,9 @@ export class Workspace {
169
274
  return out.operation;
170
275
  }
171
276
  async reset(opts = {}) {
277
+ const refusal = this.#needsVm('reset');
278
+ if (refusal)
279
+ throw refusal;
172
280
  const op = await this.#ctx.workspaces.reset(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait() });
173
281
  for (const m of this.#managers.values())
174
282
  m.invalidate();
@@ -197,7 +305,7 @@ export class Workspace {
197
305
  maxRetries: 0,
198
306
  ...(opts.transitionTimeoutMs !== undefined ? { transitionTimeoutMs: opts.transitionTimeoutMs } : {}),
199
307
  ...(this.#ctx.onProgress ? { onProgress: this.#ctx.onProgress } : {}),
200
- wake: opts.wake === undefined ? (timeoutMs, signal) => this.wake({ timeoutMs, ...(signal ? { signal } : {}) }) : opts.wake,
308
+ wake: opts.wake === undefined ? (timeoutMs, signal) => this.wake({ timeoutMs, ...(signal ? { signal } : {}), ...(agentLabel !== undefined ? { agentLabel } : {}), ...(tools !== undefined ? { tools } : {}) }) : opts.wake,
201
309
  });
202
310
  return new ToolCallCapture({ workspaceId: this.id, cell, registry: this.#ctx.captures, ...(this.#ctx.sleep !== defaultSleep ? { sleep: this.#ctx.sleep } : {}) }, opts);
203
311
  }
@@ -207,11 +315,15 @@ export class Workspace {
207
315
  }
208
316
  /** Saves this layered workspace as the next version of an organization template (contracts §19.8). */
209
317
  saveAsTemplate(params) {
318
+ const refusal = this.#needsVm('save_as_template');
319
+ if (refusal)
320
+ return Promise.reject(refusal);
210
321
  return this.#ctx.workspaces.saveAsTemplate(this.id, params);
211
322
  }
212
323
  /**
213
324
  * The workspace's changes against its template (cell gateway GET /v1/workspaces/{id}/changes; needs the `files` tool).
214
325
  * 409 workspace_not_running, or conflict with details.reason legacy_disk_layout / guest_feature_unavailable.
326
+ * File-first workspaces: NotSupportedForModeError (each execution result lists what it changed).
215
327
  */
216
328
  changes(opts = {}) {
217
329
  const { agentLabel, tools, ...params } = opts;
@@ -249,8 +361,14 @@ export class Workspace {
249
361
  userAgent: this.#ctx.userAgent,
250
362
  sleep: this.#ctx.sleep,
251
363
  ...cellOpts,
364
+ // The view's mode (undefined from an older API: calls are sent and the server decides).
365
+ mode: () => this.#view.mode,
366
+ onTreeRevision: (rev) => this.#noteTreeRevision(rev),
252
367
  ...(listener ? { onProgress: listener } : {}),
253
- wake: wake === undefined ? (timeoutMs, signal) => this.wake({ timeoutMs, ...(signal ? { signal } : {}), ...(onProgress ? { onProgress } : {}) }) : wake,
368
+ // The wake's held resume brings back this client's token (its label and tools).
369
+ wake: wake === undefined
370
+ ? (timeoutMs, signal) => this.wake({ timeoutMs, ...(signal ? { signal } : {}), ...(onProgress ? { onProgress } : {}), ...(agentLabel !== undefined ? { agentLabel } : {}), ...(tools !== undefined ? { tools } : {}) })
371
+ : wake,
254
372
  [CAPTURE_BARRIER]: () => this.#ctx.captures.settle(this.id),
255
373
  });
256
374
  this.#cells.set(k, c);
@@ -267,8 +385,16 @@ export class Workspace {
267
385
  * operation_in_progress) at once. Cell calls use it automatically when they meet `workspace_not_running`. A resume no
268
386
  * host admits within 15 minutes fails with `capacity_unavailable` (OperationFailedError, `retryable`: the workspace
269
387
  * stays suspended with its state; try again later).
388
+ *
389
+ * Since 0.9.0 the resume is held by the server until the workspace runs (contracts §22.6): one request returns the
390
+ * running workspace and a tool token for `agentLabel`/`tools`, which this handle keeps, so a tool call that woke the
391
+ * workspace is retried at once (refused call, resume, call). Timing: one `request` phase with reason `held`. A server
392
+ * that does not hold the request answers at once; the wake then waits for the operation and reads the view.
270
393
  */
271
394
  async wake(opts = {}) {
395
+ // A file-first workspace runs from creation and is never suspended (contracts §29.7): nothing to wake.
396
+ if (this.#view.mode === 'file_first')
397
+ return false;
272
398
  const trace = new Trace('wake', combineListeners(this.#ctx.onProgress, this.#tracked(opts).onProgress), { workspaceId: this.id });
273
399
  return traced(trace, () => this.#wake(opts, trace));
274
400
  }
@@ -276,17 +402,39 @@ export class Workspace {
276
402
  const deadline = Date.now() + (opts.timeoutMs ?? DEFAULT_TRANSITION_TIMEOUT_MS);
277
403
  const waitFor = (operationId) => this.#ctx.workspaces.waitForOperation(operationId, { timeoutMs: Math.max(1, deadline - Date.now()), ...(opts.signal ? { signal: opts.signal } : {}), [TRACE]: trace });
278
404
  const resumePath = `/v1/workspaces/${encodeURIComponent(this.id)}/resume`;
405
+ // The held resume's token is for the caller's client (agent label and tools).
406
+ const target = this.#heldTarget(this.tokens({ ...(opts.agentLabel !== undefined ? { agentLabel: opts.agentLabel } : {}), ...(opts.tools !== undefined ? { tools: opts.tools } : {}) }));
279
407
  for (let i = 0; i < 4; i += 1) {
280
408
  if (opts.signal?.aborted)
281
409
  throw opts.signal.reason;
282
410
  let active;
283
411
  try {
284
- // Resume returns the active resume/open when there is one, so concurrent wakes join a single operation. The
285
- // request is made here rather than through resume() so the wake is one trace.
286
- trace.phase('request', i === 0 ? null : 'retry');
287
- const { operation: op } = await this.#ctx.http.json('POST', resumePath, { idempotencyKey: randomId('op-'), onRetry: trace.onRetry }, this.#ctx.authorization);
288
- trace.observe(op);
289
- await waitFor(op.id);
412
+ // Held resume (contracts §22.6): answered once the workspace runs, with the view and a token; it returns the
413
+ // active resume/open when there is one, so concurrent wakes join a single operation. The request is made here
414
+ // rather than through resume() so the wake is one trace.
415
+ const waitS = Math.min(SERVER_WAIT_MAX_S, Math.floor((deadline - Date.now()) / 1000));
416
+ trace.phase('request', waitS >= 1 ? 'held' : i === 0 ? null : 'retry');
417
+ const answer = await this.#ctx.workspaces.requestResume(this.id, {
418
+ waitS,
419
+ agentLabel: target.agentLabel,
420
+ tools: target.tools,
421
+ idempotencyKey: randomId('op-'),
422
+ signal: opts.signal,
423
+ onRetry: trace.onRetry,
424
+ });
425
+ if (answer.operation)
426
+ trace.observe(answer.operation);
427
+ if (answer.ready) {
428
+ target.adopt(answer.workspace, answer.toolToken);
429
+ // No operation: it was already running (after a conflict this wake did wait for an operation).
430
+ return answer.operation !== null || i > 0;
431
+ }
432
+ // Not held (an older server), or not finished within the hold: wait for the operation, then read the view.
433
+ const op = answer.operation;
434
+ if (op.state === 'failed' || op.state === 'canceled')
435
+ throw new OperationFailedError(op);
436
+ if (op.state !== 'succeeded')
437
+ await waitFor(op.id);
290
438
  await trace.span('view', () => this.refresh());
291
439
  return true;
292
440
  }
@@ -318,6 +466,44 @@ export class Workspace {
318
466
  }
319
467
  throw new Error(`workspace ${this.id} did not become runnable after repeated lifecycle conflicts`);
320
468
  }
469
+ /**
470
+ * Announces an imminent tool call (cell `POST /wake-hint`, contracts §26.6; 0.9.0+) so a parked workspace is restored
471
+ * ahead of it: call it when the model starts emitting a tool call, before its arguments are complete. The agent tools
472
+ * of `workspaceTools()` send it when a call starts. Cheap and best effort: it returns what the host found. A workspace
473
+ * that is not running (suspended, or a token cannot be issued for it) is woken in the background (`wake`, not awaited
474
+ * here) so the call finds it running sooner. Other errors (network, auth) are thrown; callers that fire and forget
475
+ * should catch them.
476
+ */
477
+ async hint(opts = {}) {
478
+ // Nothing of a file-first workspace sleeps: resident, without a request.
479
+ if (this.#view.mode === 'file_first')
480
+ return { residency: 'resident', wake: null };
481
+ const { agentLabel, tools, signal } = opts;
482
+ try {
483
+ const r = await this.cell({ ...(agentLabel !== undefined ? { agentLabel } : {}), ...(tools !== undefined ? { tools } : {}), wake: null }).wakeHint(signal);
484
+ return { residency: r.residency, wake: null };
485
+ }
486
+ catch (err) {
487
+ if (!notRunning(err) || signal?.aborted)
488
+ throw err;
489
+ if (opts.wake === null)
490
+ return { residency: null, wake: null };
491
+ if (!this.#hintWake) {
492
+ const timeoutMs = opts.wakeTimeoutMs ?? DEFAULT_TRANSITION_TIMEOUT_MS;
493
+ // The default wake brings back the token of the hinted client (its label and tools).
494
+ const started = opts.wake
495
+ ? opts.wake(timeoutMs).then((r) => r !== false)
496
+ : this.wake({ timeoutMs, ...(agentLabel !== undefined ? { agentLabel } : {}), ...(tools !== undefined ? { tools } : {}) });
497
+ const shared = started.finally(() => {
498
+ if (this.#hintWake === shared)
499
+ this.#hintWake = null;
500
+ });
501
+ shared.catch(() => undefined); // nobody has to await it: a failed wake is left to the next tool call
502
+ this.#hintWake = shared;
503
+ }
504
+ return { residency: null, wake: this.#hintWake };
505
+ }
506
+ }
321
507
  /** Tools granted by the most recent token (null before one was issued). */
322
508
  get grantedTools() {
323
509
  for (const m of this.#managers.values())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Shardflux TypeScript SDK: open persistent agent workspaces by key and give your agent workspace tools (exec, files, processes, PTY, git, browser).",
6
6
  "license": "Apache-2.0",