@shardflux/sdk 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tokens.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Trace, traced } from "./progress.js";
1
2
  export class ToolTokenManager {
2
3
  workspaceId;
3
4
  agentLabel;
@@ -8,6 +9,7 @@ export class ToolTokenManager {
8
9
  #now;
9
10
  #current;
10
11
  #inflight;
12
+ #invalidated = false;
11
13
  /** Number of tokens fetched from the API (observability / tests). */
12
14
  fetched = 0;
13
15
  constructor(http, authorization, workspaceId, opts = {}) {
@@ -30,24 +32,33 @@ export class ToolTokenManager {
30
32
  #fresh(t) {
31
33
  return t !== undefined && Date.parse(t.expires_at) - this.#now() > this.#skew;
32
34
  }
33
- async get() {
35
+ /**
36
+ * The current token, or a new one when there is none or it expires within the skew. A fetch is a traced `token` call:
37
+ * `onProgress` sees it (phase `request` with the reason `initial`, `expiring` or `invalidated`) and its timing.
38
+ */
39
+ async get(onProgress) {
34
40
  if (this.#fresh(this.#current))
35
41
  return this.#current;
36
- return this.refresh();
42
+ return this.refresh(onProgress, this.#current ? 'expiring' : this.#invalidated ? 'invalidated' : 'initial');
37
43
  }
38
- /** Forces a new token (single-flight). */
39
- refresh() {
44
+ /** Forces a new token (single-flight: concurrent callers share one request, and the first caller's trace). */
45
+ refresh(onProgress, reason = 'forced') {
40
46
  this.#inflight ??= (async () => {
47
+ const trace = new Trace('token', onProgress, { workspaceId: this.workspaceId });
41
48
  try {
42
- const body = {};
43
- if (this.agentLabel !== undefined)
44
- body.agent_label = this.agentLabel;
45
- if (this.tools !== undefined)
46
- body.tools = [...this.tools];
47
- const token = await this.#http.json('POST', `/v1/workspaces/${encodeURIComponent(this.workspaceId)}/tool-tokens`, { json: body }, this.#authorization);
48
- this.fetched += 1;
49
- this.#current = token;
50
- return token;
49
+ return await traced(trace, async () => {
50
+ trace.phase('request', reason);
51
+ const body = {};
52
+ if (this.agentLabel !== undefined)
53
+ body.agent_label = this.agentLabel;
54
+ if (this.tools !== undefined)
55
+ body.tools = [...this.tools];
56
+ const token = await this.#http.json('POST', `/v1/workspaces/${encodeURIComponent(this.workspaceId)}/tool-tokens`, { json: body, onRetry: trace.onRetry }, this.#authorization);
57
+ this.fetched += 1;
58
+ this.#current = token;
59
+ this.#invalidated = false;
60
+ return token;
61
+ });
51
62
  }
52
63
  finally {
53
64
  this.#inflight = undefined;
@@ -57,5 +68,6 @@ export class ToolTokenManager {
57
68
  }
58
69
  invalidate() {
59
70
  this.#current = undefined;
71
+ this.#invalidated = true;
60
72
  }
61
73
  }
package/dist/tools.d.ts CHANGED
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Framework-neutral agent tools for a workspace (TEMPLATES.md):
3
+ *
4
+ * await agent.run({ input, tools: workspaceTools(workspace) });
5
+ *
6
+ * Each tool is { name, description, parameters (JSON Schema 2020-12), execute }.
7
+ * `execute` validates its arguments against `parameters` (the same schema the
8
+ * model saw), then calls the cell gateway through the workspace's managed tool
9
+ * token. The customer's model and agent loop stay in the customer's
10
+ * application; toOpenAITools/toAnthropicTools export the definitions in those
11
+ * providers' formats and executeToolCall dispatches a model's tool call.
12
+ */
13
+ import type { CellClientOptions } from './cell.js';
1
14
  import type { ToolName } from './tokens.js';
2
15
  import type { Workspace } from './workspace.js';
3
16
  export interface JsonSchema {
@@ -46,6 +59,10 @@ export interface WorkspaceToolsOptions {
46
59
  maxOutputBytes?: number;
47
60
  /** Working directory for exec when the model gives none (default the guest user's home). */
48
61
  defaultCwd?: string;
62
+ /** Wake on use for the tools' cell calls (CellClientOptions.wake): default `workspace.wake()`; `null` opts out. */
63
+ wake?: CellClientOptions['wake'];
64
+ /** Bound on lifecycle waits (busy waits plus wakes) per tool call (CellClientOptions.transitionTimeoutMs). */
65
+ transitionTimeoutMs?: number;
49
66
  }
50
67
  /** Builds the tool list for a workspace. Synchronous: tokens are fetched on first use. */
51
68
  export declare function workspaceTools(workspace: Workspace, opts?: WorkspaceToolsOptions): WorkspaceTool[];
package/dist/tools.js CHANGED
@@ -77,7 +77,11 @@ function clip(text, max) {
77
77
  }
78
78
  /** Builds the tool list for a workspace. Synchronous: tokens are fetched on first use. */
79
79
  export function workspaceTools(workspace, opts = {}) {
80
- const cell = () => workspace.cell(opts.agentLabel !== undefined ? { agentLabel: opts.agentLabel } : {});
80
+ const cell = () => workspace.cell({
81
+ ...(opts.agentLabel !== undefined ? { agentLabel: opts.agentLabel } : {}),
82
+ ...(opts.wake !== undefined ? { wake: opts.wake } : {}),
83
+ ...(opts.transitionTimeoutMs !== undefined ? { transitionTimeoutMs: opts.transitionTimeoutMs } : {}),
84
+ });
81
85
  const max = opts.maxOutputBytes ?? 65_536;
82
86
  const prefix = opts.prefix ?? '';
83
87
  const allowed = new Set(opts.tools ?? workspace.grantedTools ?? ALL);
@@ -2,18 +2,37 @@
2
2
  * A workspace handle: the latest view from the application API plus managed
3
3
  * tool tokens and cell clients (one per agent label / tool set).
4
4
  */
5
- import type { ClientContext, Caps, Operation, WaitOptions, WorkspaceView } from './client.js';
5
+ import type { ClientContext, DiskLayout, ForkTarget, Operation, WaitOptions, WorkspaceLifetime, WorkspaceOrigin, WorkspacePurpose, WorkspaceView } from './client.js';
6
6
  import { CellClient } from './cell.js';
7
- import type { CellClientOptions } from './cell.js';
7
+ import type { FinishedOperation, LifecycleOptions, WaitedLifecycleOptions } from './lifecycle.js';
8
+ import { Trace } from './progress.js';
9
+ import type { LifecycleTiming, ProgressListener } from './progress.js';
10
+ import { WorkspaceSecrets } from './secrets.js';
11
+ import type { CellClientOptions, WorkspaceChangesPage, WorkspaceChangesParams } from './cell.js';
12
+ import type { SaveAsTemplateParams, SaveAsTemplateResponse } from './templates.js';
8
13
  import { ToolTokenManager } from './tokens.js';
9
14
  import type { ToolName, ToolToken } from './tokens.js';
15
+ export interface WakeOptions {
16
+ /** One deadline for the whole wake, across its retries (default 120 000 ms). */
17
+ timeoutMs?: number;
18
+ signal?: AbortSignal;
19
+ /** Progress of the wake: the resume request, observed states, conflicts retried, and `done` with the timing. */
20
+ onProgress?: ProgressListener;
21
+ }
10
22
  export declare class Workspace {
11
23
  #private;
12
24
  constructor(ctx: ClientContext, view: WorkspaceView, opts?: {
13
25
  agentLabel?: string | undefined;
14
26
  tools?: ToolName[] | undefined;
15
27
  token?: ToolToken | null;
28
+ trace?: Trace;
16
29
  });
30
+ /**
31
+ * Where the time went in the last lifecycle call made through this handle: open(), wake() (also when a tool call
32
+ * woke the workspace), or a lifecycle call with `wait`. Null for handles from get()/list() until such a call.
33
+ * `formatTiming(workspace.lastTiming)` prints it.
34
+ */
35
+ get lastTiming(): LifecycleTiming | null;
17
36
  get id(): string;
18
37
  get key(): string;
19
38
  /** Observed lifecycle state reported by the cell. */
@@ -28,43 +47,116 @@ export declare class Workspace {
28
47
  get template(): WorkspaceView['template'];
29
48
  get pendingReason(): string | null;
30
49
  get activeOperation(): WorkspaceView['active_operation'];
50
+ /** persistent or session (contracts §19.11); immutable. A view without the field (older API) is persistent. */
51
+ get lifetime(): WorkspaceLifetime;
52
+ /** standard, template_draft or template_test (contracts §19.9). */
53
+ get purpose(): WorkspacePurpose;
54
+ /** legacy or layered (contracts §19.2); reset, save-as-template and changes need layered. */
55
+ get diskLayout(): DiskLayout;
56
+ /** Sessions: seconds without activity after which the session ends (null for persistent workspaces). */
57
+ get idleTimeoutSeconds(): number | null;
58
+ /** Where the disk came from: null, `{kind: "fork"}` or `{kind: "draft_state"}` (test instances). */
59
+ get origin(): WorkspaceOrigin | null;
60
+ /** How a session ended (closed, idle_timeout, draft_discarded); null while live or for a plain delete. */
61
+ get endedReason(): WorkspaceView['ended_reason'];
31
62
  /** The raw view (GET /v1/workspaces/{id}). */
32
63
  get data(): WorkspaceView;
64
+ /** Secret names bound to this workspace (injected into every exec/PTY start): `get()`, `set(names)`. */
65
+ get secrets(): WorkspaceSecrets;
33
66
  refresh(): Promise<this>;
34
67
  /** Waits for the active operation (if any) and refreshes. */
35
68
  waitUntilReady(opts?: WaitOptions): Promise<this>;
36
- delete(opts?: {
37
- idempotencyKey?: string;
38
- }): Promise<Operation>;
39
- suspend(opts?: {
40
- idempotencyKey?: string;
41
- }): Promise<Operation>;
42
- resume(opts?: {
43
- idempotencyKey?: string;
44
- }): Promise<Operation>;
45
- snapshot(opts?: {
69
+ /**
70
+ * Deletes the workspace (tool access ends at once; keys are never reused). Resolves when the delete is REQUESTED;
71
+ * with `{ wait: true }`, once it has FINISHED.
72
+ */
73
+ delete(opts: WaitedLifecycleOptions): Promise<FinishedOperation>;
74
+ delete(opts?: LifecycleOptions): Promise<Operation>;
75
+ /**
76
+ * Suspends the workspace: memory and processes are checkpointed, compute stops. Resolves when the suspend is
77
+ * REQUESTED (the operation is usually still `queued`, and the workspace still running); pass `{ wait: true }` to
78
+ * resolve once it has FINISHED, with `workspace.state` then `suspended`.
79
+ *
80
+ * await workspace.suspend({ wait: true });
81
+ */
82
+ suspend(opts: WaitedLifecycleOptions): Promise<FinishedOperation>;
83
+ suspend(opts?: LifecycleOptions): Promise<Operation>;
84
+ /**
85
+ * Resumes a suspended workspace. Resolves when the resume is REQUESTED; with `{ wait: true }`, once the workspace runs.
86
+ * Tool calls wake a suspended workspace by themselves, so this is rarely needed.
87
+ */
88
+ resume(opts: WaitedLifecycleOptions): Promise<FinishedOperation>;
89
+ resume(opts?: LifecycleOptions): Promise<Operation>;
90
+ /** Takes a snapshot. Resolves when it is REQUESTED; with `{ wait: true }`, once it is taken. */
91
+ snapshot(opts: WaitedLifecycleOptions & {
92
+ label?: string;
93
+ }): Promise<FinishedOperation>;
94
+ snapshot(opts?: LifecycleOptions & {
46
95
  label?: string;
47
- idempotencyKey?: string;
48
96
  }): Promise<Operation>;
49
- fork(target: {
50
- key: string;
51
- caps?: Caps;
52
- }, opts?: {
53
- idempotencyKey?: string;
54
- }): Promise<{
97
+ /**
98
+ * Forks into a new key. Resolves when the fork is REQUESTED (the copy's handle is returned at once); with
99
+ * `{ wait: true }`, once the copy exists, with its handle refreshed.
100
+ */
101
+ fork(target: ForkTarget, opts: WaitedLifecycleOptions): Promise<{
102
+ operation: FinishedOperation;
103
+ workspace: Workspace;
104
+ }>;
105
+ fork(target: ForkTarget, opts?: LifecycleOptions): Promise<{
55
106
  operation: Operation;
56
107
  workspace: Workspace;
57
108
  }>;
109
+ /**
110
+ * Safe in a `finally` block for any workspace. It always closes this handle's local streams (in-flight cell requests,
111
+ * exec output streams and PTY reads started through `cell()` are aborted; later `cell()` calls get fresh clients).
112
+ * For a **session** workspace it also ends the session (POST /v1/workspaces/{id}/close): the workspace is deleted and
113
+ * the `delete` operation is returned (input.reason session_closed). For a **persistent** workspace no request is made
114
+ * and null is returned: the workspace keeps running (suspend() or delete() it explicitly). With `{ wait: true }`, a
115
+ * session's close resolves once the delete has finished.
116
+ */
117
+ close(opts: WaitedLifecycleOptions): Promise<FinishedOperation | null>;
118
+ close(opts?: LifecycleOptions): Promise<Operation | null>;
119
+ /**
120
+ * Wipes every change in this layered workspace and restarts it on its template (contracts §19.12; sends
121
+ * confirm_destructive). Returns the `reset` operation: requested, or with `{ wait: true }` finished. Tool tokens of
122
+ * the old epoch are dropped.
123
+ */
124
+ reset(opts: WaitedLifecycleOptions): Promise<FinishedOperation>;
125
+ reset(opts?: LifecycleOptions): Promise<Operation>;
126
+ /** Saves this layered workspace as the next version of an organization template (contracts §19.8). */
127
+ saveAsTemplate(params: SaveAsTemplateParams): Promise<SaveAsTemplateResponse>;
128
+ /**
129
+ * The workspace's changes against its template (cell gateway GET /v1/workspaces/{id}/changes; needs the `files` tool).
130
+ * 409 workspace_not_running, or conflict with details.reason legacy_disk_layout / guest_feature_unavailable.
131
+ */
132
+ changes(opts?: WorkspaceChangesParams & {
133
+ agentLabel?: string;
134
+ tools?: ToolName[];
135
+ }): Promise<WorkspaceChangesPage>;
58
136
  /** Token manager for an agent label / tool set (defaults: those given to open()). */
59
137
  tokens(opts?: {
60
138
  agentLabel?: string;
61
139
  tools?: ToolName[];
62
140
  }): ToolTokenManager;
63
- /** Typed client for the cell gateway tools of this workspace. */
141
+ /**
142
+ * Typed client for the cell gateway tools of this workspace. Calls wake the workspace on use (`wake()`, bounded by
143
+ * `transitionTimeoutMs`); pass `wake: null` to get `workspace_not_running` back instead. One client per agent label,
144
+ * tool set and transition settings; the other options apply when that client is first created.
145
+ */
64
146
  cell(opts?: {
65
147
  agentLabel?: string;
66
148
  tools?: ToolName[];
67
149
  } & CellClientOptions): CellClient;
150
+ /**
151
+ * Makes a suspended (or suspending/resuming) workspace run again and resolves once it does (contracts §20.4): resume,
152
+ * or join the active resume/open; an active suspend (or other operation) is waited out first. Resolves `true` when it
153
+ * resumed or waited for a lifecycle operation, `false` when the workspace was already running. One deadline
154
+ * (`timeoutMs`, default 120 000 ms) covers every step. Throws OperationFailedError when the resume/open fails,
155
+ * OperationTimeoutError (naming the operation, its state and reason) when it is still queued, running or
156
+ * capacity_pending at the deadline, and any other API error (a conflict other than already_running /
157
+ * operation_in_progress) at once. Cell calls use it automatically when they meet `workspace_not_running`.
158
+ */
159
+ wake(opts?: WakeOptions): Promise<boolean>;
68
160
  /** Tools granted by the most recent token (null before one was issued). */
69
161
  get grantedTools(): ToolName[] | null;
70
162
  }
package/dist/workspace.js CHANGED
@@ -1,4 +1,9 @@
1
- import { CellClient } from "./cell.js";
1
+ import { CellClient, DEFAULT_TRANSITION_TIMEOUT_MS } from "./cell.js";
2
+ import { OperationFailedError, ShardfluxApiError } from "./errors.js";
3
+ import { randomId } from "./http.js";
4
+ import { AFTER_WAIT, TRACE } from "./lifecycle.js";
5
+ import { Trace, combineListeners, traced } from "./progress.js";
6
+ import { WorkspaceSecrets } from "./secrets.js";
2
7
  import { ToolTokenManager } from "./tokens.js";
3
8
  export class Workspace {
4
9
  #view;
@@ -6,13 +11,42 @@ export class Workspace {
6
11
  #defaults;
7
12
  #managers = new Map();
8
13
  #cells = new Map();
14
+ /** The open() that produced this handle; its timing is final once open() has returned. */
15
+ #openTrace;
16
+ #lastTiming;
9
17
  constructor(ctx, view, opts = {}) {
10
18
  this.#ctx = ctx;
11
19
  this.#view = view;
12
20
  this.#defaults = { agentLabel: opts.agentLabel, tools: opts.tools };
21
+ this.#openTrace = opts.trace;
13
22
  if (opts.token)
14
23
  this.tokens().seed(opts.token);
15
24
  }
25
+ /**
26
+ * Where the time went in the last lifecycle call made through this handle: open(), wake() (also when a tool call
27
+ * woke the workspace), or a lifecycle call with `wait`. Null for handles from get()/list() until such a call.
28
+ * `formatTiming(workspace.lastTiming)` prints it.
29
+ */
30
+ get lastTiming() {
31
+ return this.#lastTiming ?? this.#openTrace?.finished ?? null;
32
+ }
33
+ /** Adds this handle's timing capture to a call's listener. */
34
+ #tracked(opts) {
35
+ const capture = (e) => {
36
+ if (e.type === 'done' && e.action !== 'token')
37
+ this.#lastTiming = e.timing;
38
+ };
39
+ return { ...opts, onProgress: combineListeners(opts.onProgress, capture) };
40
+ }
41
+ /** Refreshes the view inside a waited call's trace (a deleted workspace may no longer be readable). */
42
+ #refreshAfterWait() {
43
+ return async (trace) => {
44
+ await trace.span('view', () => this.refresh()).catch((e) => {
45
+ if (!(e instanceof ShardfluxApiError && e.status === 404))
46
+ throw e;
47
+ });
48
+ };
49
+ }
16
50
  get id() {
17
51
  return this.#view.id;
18
52
  }
@@ -49,10 +83,38 @@ export class Workspace {
49
83
  get activeOperation() {
50
84
  return this.#view.active_operation;
51
85
  }
86
+ /** persistent or session (contracts §19.11); immutable. A view without the field (older API) is persistent. */
87
+ get lifetime() {
88
+ return this.#view.lifetime ?? 'persistent';
89
+ }
90
+ /** standard, template_draft or template_test (contracts §19.9). */
91
+ get purpose() {
92
+ return this.#view.purpose ?? 'standard';
93
+ }
94
+ /** legacy or layered (contracts §19.2); reset, save-as-template and changes need layered. */
95
+ get diskLayout() {
96
+ return this.#view.disk_layout ?? 'legacy';
97
+ }
98
+ /** Sessions: seconds without activity after which the session ends (null for persistent workspaces). */
99
+ get idleTimeoutSeconds() {
100
+ return this.#view.idle_timeout_seconds ?? null;
101
+ }
102
+ /** Where the disk came from: null, `{kind: "fork"}` or `{kind: "draft_state"}` (test instances). */
103
+ get origin() {
104
+ return this.#view.origin ?? null;
105
+ }
106
+ /** How a session ended (closed, idle_timeout, draft_discarded); null while live or for a plain delete. */
107
+ get endedReason() {
108
+ return this.#view.ended_reason ?? null;
109
+ }
52
110
  /** The raw view (GET /v1/workspaces/{id}). */
53
111
  get data() {
54
112
  return this.#view;
55
113
  }
114
+ /** Secret names bound to this workspace (injected into every exec/PTY start): `get()`, `set(names)`. */
115
+ get secrets() {
116
+ return new WorkspaceSecrets(this.#ctx, this.id);
117
+ }
56
118
  async refresh() {
57
119
  this.#view = await this.#ctx.http.json('GET', `/v1/workspaces/${encodeURIComponent(this.id)}`, {}, this.#ctx.authorization);
58
120
  return this;
@@ -65,19 +127,49 @@ export class Workspace {
65
127
  return this.refresh();
66
128
  }
67
129
  delete(opts = {}) {
68
- return this.#ctx.workspaces.delete(this.id, opts);
130
+ return this.#ctx.workspaces.delete(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait() });
69
131
  }
70
132
  suspend(opts = {}) {
71
- return this.#ctx.workspaces.suspend(this.id, opts);
133
+ return this.#ctx.workspaces.suspend(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait() });
72
134
  }
73
135
  resume(opts = {}) {
74
- return this.#ctx.workspaces.resume(this.id, opts);
136
+ return this.#ctx.workspaces.resume(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait() });
75
137
  }
76
138
  snapshot(opts = {}) {
77
- return this.#ctx.workspaces.snapshot(this.id, opts);
139
+ return this.#ctx.workspaces.snapshot(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait() });
78
140
  }
79
141
  fork(target, opts = {}) {
80
- return this.#ctx.workspaces.fork(this.id, target, opts);
142
+ return this.#ctx.workspaces.fork(this.id, target, this.#tracked(opts));
143
+ }
144
+ async close(opts = {}) {
145
+ for (const c of this.#cells.values())
146
+ c.close();
147
+ this.#cells.clear();
148
+ if (this.lifetime !== 'session')
149
+ return null;
150
+ const out = await this.#ctx.workspaces.closeWithView(this.id, this.#tracked(opts));
151
+ this.#view = out.workspace;
152
+ for (const m of this.#managers.values())
153
+ m.invalidate();
154
+ return out.operation;
155
+ }
156
+ async reset(opts = {}) {
157
+ const op = await this.#ctx.workspaces.reset(this.id, { ...this.#tracked(opts), [AFTER_WAIT]: this.#refreshAfterWait() });
158
+ for (const m of this.#managers.values())
159
+ m.invalidate();
160
+ return op;
161
+ }
162
+ /** Saves this layered workspace as the next version of an organization template (contracts §19.8). */
163
+ saveAsTemplate(params) {
164
+ return this.#ctx.workspaces.saveAsTemplate(this.id, params);
165
+ }
166
+ /**
167
+ * The workspace's changes against its template (cell gateway GET /v1/workspaces/{id}/changes; needs the `files` tool).
168
+ * 409 workspace_not_running, or conflict with details.reason legacy_disk_layout / guest_feature_unavailable.
169
+ */
170
+ changes(opts = {}) {
171
+ const { agentLabel, tools, ...params } = opts;
172
+ return this.cell({ ...(agentLabel !== undefined ? { agentLabel } : {}), ...(tools !== undefined ? { tools } : {}) }).changes(params);
81
173
  }
82
174
  /** Token manager for an agent label / tool set (defaults: those given to open()). */
83
175
  tokens(opts = {}) {
@@ -91,18 +183,92 @@ export class Workspace {
91
183
  }
92
184
  return m;
93
185
  }
94
- /** Typed client for the cell gateway tools of this workspace. */
186
+ /**
187
+ * Typed client for the cell gateway tools of this workspace. Calls wake the workspace on use (`wake()`, bounded by
188
+ * `transitionTimeoutMs`); pass `wake: null` to get `workspace_not_running` back instead. One client per agent label,
189
+ * tool set and transition settings; the other options apply when that client is first created.
190
+ */
95
191
  cell(opts = {}) {
96
- const { agentLabel, tools, ...cellOpts } = opts;
192
+ const { agentLabel, tools, wake, onProgress, ...cellOpts } = opts;
97
193
  const manager = this.tokens({ ...(agentLabel !== undefined ? { agentLabel } : {}), ...(tools !== undefined ? { tools } : {}) });
98
- const k = `${JSON.stringify([agentLabel ?? null, tools ?? null])}`;
194
+ const wakeMode = wake === null ? 'off' : wake ? 'custom' : 'on';
195
+ const k = `${JSON.stringify([agentLabel ?? null, tools ?? null, wakeMode, cellOpts.transitionTimeoutMs ?? null])}`;
99
196
  let c = this.#cells.get(k);
100
197
  if (!c) {
101
- c = new CellClient(this.id, manager, { fetch: this.#ctx.fetch, userAgent: this.#ctx.userAgent, sleep: this.#ctx.sleep, ...cellOpts });
198
+ // Tool-call events (token fetches, busy waits, retries) go to the client listener and this cell's own; a wake
199
+ // adds the client listener itself.
200
+ const listener = combineListeners(this.#ctx.onProgress, onProgress);
201
+ c = new CellClient(this.id, manager, {
202
+ fetch: this.#ctx.fetch,
203
+ userAgent: this.#ctx.userAgent,
204
+ sleep: this.#ctx.sleep,
205
+ ...cellOpts,
206
+ ...(listener ? { onProgress: listener } : {}),
207
+ wake: wake === undefined ? (timeoutMs, signal) => this.wake({ timeoutMs, ...(signal ? { signal } : {}), ...(onProgress ? { onProgress } : {}) }) : wake,
208
+ });
102
209
  this.#cells.set(k, c);
103
210
  }
104
211
  return c;
105
212
  }
213
+ /**
214
+ * Makes a suspended (or suspending/resuming) workspace run again and resolves once it does (contracts §20.4): resume,
215
+ * or join the active resume/open; an active suspend (or other operation) is waited out first. Resolves `true` when it
216
+ * resumed or waited for a lifecycle operation, `false` when the workspace was already running. One deadline
217
+ * (`timeoutMs`, default 120 000 ms) covers every step. Throws OperationFailedError when the resume/open fails,
218
+ * OperationTimeoutError (naming the operation, its state and reason) when it is still queued, running or
219
+ * capacity_pending at the deadline, and any other API error (a conflict other than already_running /
220
+ * operation_in_progress) at once. Cell calls use it automatically when they meet `workspace_not_running`.
221
+ */
222
+ async wake(opts = {}) {
223
+ const trace = new Trace('wake', combineListeners(this.#ctx.onProgress, this.#tracked(opts).onProgress), { workspaceId: this.id });
224
+ return traced(trace, () => this.#wake(opts, trace));
225
+ }
226
+ async #wake(opts, trace) {
227
+ const deadline = Date.now() + (opts.timeoutMs ?? DEFAULT_TRANSITION_TIMEOUT_MS);
228
+ const waitFor = (operationId) => this.#ctx.workspaces.waitForOperation(operationId, { timeoutMs: Math.max(1, deadline - Date.now()), ...(opts.signal ? { signal: opts.signal } : {}), [TRACE]: trace });
229
+ const resumePath = `/v1/workspaces/${encodeURIComponent(this.id)}/resume`;
230
+ for (let i = 0; i < 4; i += 1) {
231
+ if (opts.signal?.aborted)
232
+ throw opts.signal.reason;
233
+ let active;
234
+ try {
235
+ // Resume returns the active resume/open when there is one, so concurrent wakes join a single operation. The
236
+ // request is made here rather than through resume() so the wake is one trace.
237
+ trace.phase('request', i === 0 ? null : 'retry');
238
+ const { operation: op } = await this.#ctx.http.json('POST', resumePath, { idempotencyKey: randomId('op-'), onRetry: trace.onRetry }, this.#ctx.authorization);
239
+ trace.observe(op);
240
+ await waitFor(op.id);
241
+ await trace.span('view', () => this.refresh());
242
+ return true;
243
+ }
244
+ catch (e) {
245
+ if (!(e instanceof ShardfluxApiError) || e.code !== 'conflict')
246
+ throw e;
247
+ if (e.reason === 'already_running') {
248
+ if (i > 0)
249
+ await trace.span('view', () => this.refresh());
250
+ return i > 0;
251
+ }
252
+ if (e.reason !== 'operation_in_progress')
253
+ throw e;
254
+ const id = e.operationId ?? e.details?.['active_operation_id'];
255
+ active = typeof id === 'string' ? id : undefined;
256
+ trace.retry({ request: `POST ${resumePath}`, attempt: i + 1, cause: `conflict operation_in_progress${active ? ` (joins ${active})` : ''}`, delayMs: 0 });
257
+ }
258
+ if (active === undefined)
259
+ continue; // a concurrent start raced ours: ask again
260
+ try {
261
+ await waitFor(active);
262
+ }
263
+ catch (e) {
264
+ // A failed open/resume means the workspace will not run: fail fast. Another kind that failed (a suspend,
265
+ // snapshot or fork) leaves the workspace as it was, so the next resume attempt decides.
266
+ if (!(e instanceof OperationFailedError) || e.operation.kind === 'open' || e.operation.kind === 'resume')
267
+ throw e;
268
+ }
269
+ }
270
+ throw new Error(`workspace ${this.id} did not become runnable after repeated lifecycle conflicts`);
271
+ }
106
272
  /** Tools granted by the most recent token (null before one was issued). */
107
273
  get grantedTools() {
108
274
  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.5.0",
3
+ "version": "0.6.1",
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",
@@ -30,6 +30,7 @@
30
30
  "files": [
31
31
  "dist",
32
32
  "README.md",
33
+ "CHANGELOG.md",
33
34
  "LICENSE"
34
35
  ],
35
36
  "publishConfig": {