@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/client.js CHANGED
@@ -1,12 +1,32 @@
1
1
  import { OperationFailedError, OperationTimeoutError, ShardfluxApiError } from "./errors.js";
2
- import { HttpClient, SDK_VERSION, defaultSleep, randomId } from "./http.js";
2
+ import { HttpClient, SDK_VERSION, SERVER_WAIT_MAX_S, defaultFetch, defaultSleep, pollWithWait, randomId } from "./http.js";
3
3
  import { Workspace } from "./workspace.js";
4
4
  import { AuditApi } from "./audit.js";
5
5
  import { EgressPolicyApi } from "./egress.js";
6
6
  import { SecretsApi } from "./secrets.js";
7
- import { TemplatesApi } from "./templates.js";
7
+ import { TemplatesApi, saveAsTemplateBody } from "./templates.js";
8
8
  import { UsageApi } from "./usage.js";
9
9
  import { VolumesApi } from "./volumes.js";
10
+ import { AFTER_WAIT, TRACE, runLifecycle } from "./lifecycle.js";
11
+ import { Trace, combineListeners, traced } from "./progress.js";
12
+ /**
13
+ * The workspace a key names (contracts §19.11): the live row (deleted_at null) when there is one, since at most one live
14
+ * workspace holds a key; otherwise the newest tombstone (ended sessions leave tombstones with the same key, and a
15
+ * deleted persistent key keeps its tombstone). Null when no row has exactly this key. Rows need not be sorted.
16
+ */
17
+ export function pickByKey(rows, key) {
18
+ let tomb = null;
19
+ for (const r of rows) {
20
+ if (r.workspace_key !== key)
21
+ continue;
22
+ if (r.deleted_at === null)
23
+ return r;
24
+ // UUIDv7 ids sort by creation time: the greatest id is the newest tombstone.
25
+ if (tomb === null || r.id > tomb.id)
26
+ tomb = r;
27
+ }
28
+ return tomb;
29
+ }
10
30
  const TERMINAL = new Set(['succeeded', 'failed', 'canceled']);
11
31
  /** Races the injected sleep against the signal (the injected sleep itself may not be abortable). */
12
32
  function abortableSleep(sleep, ms, signal) {
@@ -23,6 +43,8 @@ function abortableSleep(sleep, ms, signal) {
23
43
  }
24
44
  export class WorkspacesApi {
25
45
  #ctx;
46
+ /** The cell endpoint of the last tool token seen: an open pre-connects to it while the server works (contracts §22.3). */
47
+ #cellHint = null;
26
48
  constructor(ctx) {
27
49
  this.#ctx = ctx;
28
50
  }
@@ -39,6 +61,8 @@ export class WorkspacesApi {
39
61
  * Opens a workspace by key: creates it from the template's latest published version on first
40
62
  * use, reconnects (or resumes) afterwards; never resets an existing workspace. Waits until it is
41
63
  * ready unless `wait: false`; on timeout throws OperationTimeoutError carrying the operation id.
64
+ * The timing of the open (client phases, retries, the operation's server timing) is on `workspace.lastTiming`, on
65
+ * `onProgress` as it happens, and on the error's `timing` when the open fails.
42
66
  */
43
67
  async open(params) {
44
68
  const body = { key: params.key, template: params.template };
@@ -50,29 +74,130 @@ export class WorkspacesApi {
50
74
  body.agent_label = params.agentLabel;
51
75
  if (params.tools !== undefined)
52
76
  body.tools = params.tools;
53
- const res = await this.#http.jsonWithStatus('POST', '/v1/workspaces/open', { json: body, idempotencyKey: params.idempotencyKey ?? randomId('open-') }, this.#auth);
54
- const wrapOpts = { agentLabel: params.agentLabel, tools: params.tools, token: res.body.tool_token };
55
- if (res.status === 200 || params.wait === false || res.body.operation === null)
56
- return this.#wrap(res.body.workspace, wrapOpts);
57
- await this.waitForOperation(res.body.operation.id, params.wait ?? {});
58
- return this.#wrap(await this.#getView(res.body.workspace.id), { agentLabel: params.agentLabel, tools: params.tools });
77
+ if (params.secrets !== undefined)
78
+ body.secrets = params.secrets;
79
+ if (params.lifetime !== undefined)
80
+ body.lifetime = params.lifetime;
81
+ const waitOpts = params.wait === false ? null : (params.wait ?? {});
82
+ const trace = new Trace('open', combineListeners(this.#ctx().onProgress, params.onProgress, waitOpts?.onProgress));
83
+ return traced(trace, async () => {
84
+ // The wait's signal also ends the held request (it can take up to 20 s), not only the polls after it.
85
+ const init = { json: body, idempotencyKey: params.idempotencyKey ?? randomId('open-'), onRetry: trace.onRetry, ...(waitOpts?.signal ? { signal: waitOpts.signal } : {}) };
86
+ const started = Date.now();
87
+ const timeoutMs = waitOpts?.timeoutMs ?? 300_000;
88
+ if (waitOpts && waitOpts.serverWait !== false) {
89
+ // Held open (contracts §22.3): the server answers once the operation is terminal (200 with the running workspace
90
+ // and a tool token) or the wait elapsed (202); a server without it answers 202 at once and the poll below runs.
91
+ const s = Math.min(SERVER_WAIT_MAX_S, Math.floor(timeoutMs / 1000));
92
+ if (s >= 1) {
93
+ init.headers = { prefer: `wait=${s}` };
94
+ init.timeoutMs = Math.max(this.#http.opts.timeoutMs, s * 1000 + 10_000);
95
+ }
96
+ this.#preconnect();
97
+ }
98
+ const held = init.headers?.prefer !== undefined;
99
+ trace.phase('request', held ? 'held' : null);
100
+ const res = await this.#http.jsonWithStatus('POST', '/v1/workspaces/open', init, this.#auth);
101
+ trace.workspaceId = res.body.workspace.id;
102
+ if (res.body.operation)
103
+ trace.observe(res.body.operation);
104
+ this.#noteToken(res.body.tool_token);
105
+ const wrapOpts = { agentLabel: params.agentLabel, tools: params.tools, token: res.body.tool_token, trace };
106
+ if (res.status === 200 || params.wait === false || res.body.operation === null)
107
+ return this.#wrap(res.body.workspace, wrapOpts);
108
+ if (TERMINAL.has(res.body.operation.state)) {
109
+ if (res.body.operation.state !== 'succeeded')
110
+ throw new OperationFailedError(res.body.operation);
111
+ }
112
+ else {
113
+ // A held open already spent part of the budget; without the preference the budget is the poll's (as before).
114
+ const wait = { ...(params.wait ?? {}), ...(held ? { timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)) } : {}), [TRACE]: trace };
115
+ await this.waitForOperation(res.body.operation.id, wait);
116
+ }
117
+ // Ready: read the view and issue the first tool token in parallel (a 200 open returns both at once). A token
118
+ // failure (e.g. a key without tool permissions) is left to the first tool call, which fetches and reports it.
119
+ const id = res.body.workspace.id;
120
+ const [view, token] = await Promise.all([
121
+ trace.span('view', () => this.#getView(id, trace.onRetry)),
122
+ trace.span('token', () => this.#issueToken(id, params.agentLabel, params.tools, trace.onRetry)),
123
+ ]);
124
+ return this.#wrap(view, { ...wrapOpts, token });
125
+ });
126
+ }
127
+ #noteToken(token) {
128
+ if (token?.cell_endpoint)
129
+ this.#cellHint = token.cell_endpoint;
59
130
  }
60
131
  /**
61
- * Polls GET /v1/operations/{id} with bounded exponential backoff (+-20 % jitter) until it
62
- * succeeds (resolves), fails or is canceled (OperationFailedError), or `timeoutMs` passes
63
- * (OperationTimeoutError; the operation keeps running and can be awaited again).
132
+ * Opens a connection to the last known cell endpoint in the background (GET /healthz, body discarded), so the
133
+ * first tool call after the open reuses it instead of paying the TCP and TLS handshakes. Best effort: errors are
134
+ * ignored, and a fetch that closes connections (Connection: close) simply gains nothing.
135
+ */
136
+ #preconnect() {
137
+ const hint = this.#cellHint;
138
+ if (!hint)
139
+ return;
140
+ const { fetch: f, userAgent } = this.#ctx();
141
+ let url;
142
+ try {
143
+ url = new URL('/healthz', hint).toString();
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ void f(url, { method: 'GET', headers: { 'user-agent': userAgent }, signal: AbortSignal.timeout(5_000) })
149
+ .then((r) => r.body?.cancel())
150
+ .catch(() => undefined);
151
+ }
152
+ async #issueToken(workspaceId, agentLabel, tools, onRetry) {
153
+ const body = {};
154
+ if (agentLabel !== undefined)
155
+ body.agent_label = agentLabel;
156
+ if (tools !== undefined)
157
+ body.tools = [...tools];
158
+ try {
159
+ const token = await this.#http.json('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/tool-tokens`, { json: body, ...(onRetry ? { onRetry } : {}) }, this.#auth);
160
+ this.#noteToken(token);
161
+ return token;
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ /**
168
+ * Waits for an operation: each GET /v1/operations/{id} asks the server to hold the response until the state changes
169
+ * (`Prefer: wait`, at most 20 s, contracts §3), so completion is seen within one notification of the commit. A server
170
+ * that does not wait is polled with bounded exponential backoff (+-20 % jitter). Resolves when the operation
171
+ * succeeds; throws OperationFailedError when it fails or is canceled, OperationTimeoutError after `timeoutMs` (the
172
+ * operation keeps running and can be awaited again). Both errors carry the wait's `timing`; `onProgress` sees each
173
+ * state change (queued, capacity_pending, running, with the server's reason) as it is observed.
64
174
  */
65
175
  async waitForOperation(operationId, opts = {}) {
66
- const { sleep } = this.#ctx();
176
+ const inherited = opts[TRACE];
177
+ const trace = inherited ?? new Trace('wait', combineListeners(this.#ctx().onProgress, opts.onProgress), { operationId });
178
+ const run = () => this.#poll(operationId, opts, trace);
179
+ if (inherited)
180
+ return run();
181
+ // A wait on its own starts with its first poll: a held poll can take seconds before the first state is known.
182
+ trace.phase('request');
183
+ return traced(trace, run);
184
+ }
185
+ async #poll(operationId, opts, trace) {
186
+ const { sleep, http, authorization } = this.#ctx();
67
187
  const timeoutMs = opts.timeoutMs ?? 300_000;
68
188
  const maxInterval = opts.maxPollIntervalMs ?? 5_000;
69
189
  let interval = opts.pollIntervalMs ?? 250;
70
190
  const started = Date.now();
71
191
  const aborted = () => (opts.signal?.reason instanceof Error ? opts.signal.reason : new Error('aborted'));
192
+ let lastKey = null;
72
193
  for (;;) {
73
194
  if (opts.signal?.aborted)
74
195
  throw aborted();
75
- const operation = await this.getOperation(operationId, opts.signal ? { signal: opts.signal } : {});
196
+ const waitS = opts.serverWait === false ? 0 : (timeoutMs - (Date.now() - started)) / 1000;
197
+ const t0 = Date.now();
198
+ const { body, applied } = await pollWithWait(http, `/v1/operations/${encodeURIComponent(operationId)}`, authorization, waitS, opts.signal, trace.onRetry);
199
+ const operation = body.operation;
200
+ trace.observe(operation);
76
201
  if (operation.state === 'succeeded')
77
202
  return operation;
78
203
  if (TERMINAL.has(operation.state))
@@ -80,6 +205,13 @@ export class WorkspacesApi {
80
205
  const waited = Date.now() - started;
81
206
  if (waited >= timeoutMs)
82
207
  throw new OperationTimeoutError(operation, waited);
208
+ const key = `${operation.state}/${operation.state_reason ?? ''}`;
209
+ // The server held the poll (or answered a change): ask again at once. An applied wait that returned quickly with
210
+ // no change falls through to the backoff, so a misbehaving server can never make this loop spin.
211
+ const changed = key !== lastKey;
212
+ lastKey = key;
213
+ if (applied && (changed || Date.now() - t0 >= 1_000))
214
+ continue;
83
215
  const jitter = interval * 0.2 * (Math.random() * 2 - 1);
84
216
  const delay = Math.max(10, Math.min(interval + jitter, timeoutMs - waited));
85
217
  // The wait itself is abortable: a signal ends it at once instead of after the sleep.
@@ -94,8 +226,8 @@ export class WorkspacesApi {
94
226
  const { operation } = await this.#http.json('GET', `/v1/operations/${encodeURIComponent(operationId)}`, opts.signal ? { signal: opts.signal } : {}, this.#auth);
95
227
  return operation;
96
228
  }
97
- async #getView(workspaceId) {
98
- return this.#http.json('GET', `/v1/workspaces/${encodeURIComponent(workspaceId)}`, {}, this.#auth);
229
+ async #getView(workspaceId, onRetry) {
230
+ return this.#http.json('GET', `/v1/workspaces/${encodeURIComponent(workspaceId)}`, onRetry ? { onRetry } : {}, this.#auth);
99
231
  }
100
232
  async get(workspaceId, opts = {}) {
101
233
  return this.#wrap(await this.#getView(workspaceId), opts);
@@ -109,6 +241,8 @@ export class WorkspacesApi {
109
241
  project_id: params.projectId,
110
242
  organization_id: params.organizationId,
111
243
  include_deleted: params.includeDeleted,
244
+ lifetime: params.lifetime,
245
+ purpose: params.purpose,
112
246
  limit: params.limit,
113
247
  cursor: params.cursor,
114
248
  },
@@ -124,25 +258,98 @@ export class WorkspacesApi {
124
258
  cursor = page.nextCursor ?? undefined;
125
259
  } while (cursor);
126
260
  }
127
- async #lifecycle(method, path, json, idempotencyKey) {
128
- return this.#http.json(method, path, { ...(json === undefined ? {} : { json }), idempotencyKey: idempotencyKey ?? randomId('op-') }, this.#auth);
261
+ /**
262
+ * Looks a workspace up by its exact key across every lifetime and purpose (`lifetime=any&purpose=any`), preferring the
263
+ * live workspace over tombstones of ended sessions or deleted workspaces with the same key (see pickByKey). Null when
264
+ * no workspace of the project has the key. This is the lookup the CLI and the MCP server use.
265
+ */
266
+ async findByKey(key, opts = {}) {
267
+ const includeDeleted = opts.includeDeleted !== false;
268
+ const seen = [];
269
+ let cursor;
270
+ do {
271
+ if (opts.signal?.aborted)
272
+ throw opts.signal.reason instanceof Error ? opts.signal.reason : new Error('aborted');
273
+ const page = await this.#http.json('GET', '/v1/workspaces', {
274
+ query: {
275
+ key_prefix: key,
276
+ lifetime: 'any',
277
+ purpose: 'any',
278
+ include_deleted: includeDeleted,
279
+ project_id: opts.projectId,
280
+ organization_id: opts.organizationId,
281
+ limit: 200,
282
+ cursor,
283
+ },
284
+ ...(opts.signal ? { signal: opts.signal } : {}),
285
+ }, this.#auth);
286
+ const live = page.data.find((v) => v.workspace_key === key && v.deleted_at === null);
287
+ if (live)
288
+ return this.#wrap(live, { agentLabel: opts.agentLabel, tools: opts.tools });
289
+ seen.push(...page.data.filter((v) => v.workspace_key === key));
290
+ cursor = page.next_cursor ?? undefined;
291
+ } while (cursor);
292
+ const hit = pickByKey(seen, key);
293
+ return hit ? this.#wrap(hit, { agentLabel: opts.agentLabel, tools: opts.tools }) : null;
294
+ }
295
+ async #lifecycle(method, path, json, idempotencyKey, init = {}) {
296
+ return this.#http.json(method, path, { ...(json === undefined ? {} : { json }), idempotencyKey: idempotencyKey ?? randomId('op-'), ...init }, this.#auth);
297
+ }
298
+ #op(kind, workspaceId, json, opts) {
299
+ const path = `/v1/workspaces/${encodeURIComponent(workspaceId)}${kind === 'delete' ? '' : `/${kind}`}`;
300
+ return runLifecycle(this.#ctx(), kind, workspaceId, async (init) => (await this.#lifecycle(kind === 'delete' ? 'DELETE' : 'POST', path, json, opts.idempotencyKey, init)).operation, opts);
129
301
  }
130
- /** Tombstones the workspace now (tool access revoked); storage cleanup happens asynchronously. */
131
- async delete(workspaceId, opts = {}) {
132
- return (await this.#lifecycle('DELETE', `/v1/workspaces/${encodeURIComponent(workspaceId)}`, undefined, opts.idempotencyKey)).operation;
302
+ delete(workspaceId, opts = {}) {
303
+ return this.#op('delete', workspaceId, undefined, opts);
133
304
  }
134
- async suspend(workspaceId, opts = {}) {
135
- return (await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/suspend`, undefined, opts.idempotencyKey)).operation;
305
+ suspend(workspaceId, opts = {}) {
306
+ return this.#op('suspend', workspaceId, undefined, opts);
136
307
  }
137
- async resume(workspaceId, opts = {}) {
138
- return (await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/resume`, undefined, opts.idempotencyKey)).operation;
308
+ resume(workspaceId, opts = {}) {
309
+ return this.#op('resume', workspaceId, undefined, opts);
139
310
  }
140
- async snapshot(workspaceId, opts = {}) {
141
- return (await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/snapshot`, opts.label === undefined ? {} : { label: opts.label }, opts.idempotencyKey)).operation;
311
+ snapshot(workspaceId, opts = {}) {
312
+ return this.#op('snapshot', workspaceId, opts.label === undefined ? {} : { label: opts.label }, opts);
313
+ }
314
+ async close(workspaceId, opts = {}) {
315
+ return (await this.closeWithView(workspaceId, opts)).operation;
316
+ }
317
+ /** close() plus the workspace view after the close (a tombstone). */
318
+ async closeWithView(workspaceId, opts = {}) {
319
+ let workspace;
320
+ const operation = await runLifecycle(this.#ctx(), 'close', workspaceId, async (init) => {
321
+ const out = await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/close`, undefined, opts.idempotencyKey, init);
322
+ workspace = out.workspace;
323
+ return out.operation;
324
+ }, opts);
325
+ return { operation, workspace: workspace };
326
+ }
327
+ reset(workspaceId, opts = {}) {
328
+ const body = { confirm_destructive: true };
329
+ return this.#op('reset', workspaceId, body, opts);
330
+ }
331
+ /**
332
+ * Saves a layered workspace as the next version of an organization template (contracts §19.8). A running workspace is
333
+ * captured briefly (`operation`, layer_snapshot); poll `build` with templates.builds.waitForBuild. Owners/admins and
334
+ * API keys with a tool permission only.
335
+ */
336
+ saveAsTemplate(workspaceId, params) {
337
+ return this.#http.json('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/save-as-template`, { json: saveAsTemplateBody(params), idempotencyKey: params.idempotencyKey ?? randomId('save-') }, this.#auth);
142
338
  }
143
339
  async fork(workspaceId, target, opts = {}) {
144
- const out = await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/fork`, target, opts.idempotencyKey);
145
- return { operation: out.operation, workspace: this.#wrap(out.workspace) };
340
+ let copy;
341
+ const operation = await runLifecycle(this.#ctx(), 'fork', workspaceId, async (init) => {
342
+ const out = await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/fork`, target, opts.idempotencyKey, init);
343
+ copy = this.#wrap(out.workspace);
344
+ return out.operation;
345
+ }, {
346
+ ...opts,
347
+ [AFTER_WAIT]: async (trace, op) => {
348
+ await trace.span('view', () => copy.refresh());
349
+ await opts[AFTER_WAIT]?.(trace, op);
350
+ },
351
+ });
352
+ return { operation, workspace: copy };
146
353
  }
147
354
  async operations(workspaceId, params = {}) {
148
355
  const page = await this.#http.json('GET', `/v1/workspaces/${encodeURIComponent(workspaceId)}/operations`, { query: { limit: params.limit, cursor: params.cursor, state: params.state, kind: params.kind } }, this.#auth);
@@ -203,7 +410,7 @@ export class Shardflux {
203
410
  constructor(opts) {
204
411
  if (!/^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/.test(opts.apiKey))
205
412
  throw new Error('apiKey must be a Shardflux project key (sfk_<key_id>_<secret>)');
206
- const f = opts.fetch ?? fetch;
413
+ const f = opts.fetch ?? defaultFetch();
207
414
  const userAgent = opts.userAgent ?? `shardflux-sdk-ts/${SDK_VERSION}`;
208
415
  const sleep = opts.sleep ?? defaultSleep;
209
416
  this.workspaces = new WorkspacesApi(() => this.#ctx);
@@ -221,6 +428,7 @@ export class Shardflux {
221
428
  userAgent,
222
429
  sleep,
223
430
  workspaces: this.workspaces,
431
+ onProgress: opts.onProgress,
224
432
  };
225
433
  }
226
434
  /** The authenticated principal (the API key, its organization and project). */
package/dist/errors.d.ts CHANGED
@@ -1,10 +1,22 @@
1
1
  import type { components as AppComponents } from './generated/app-api.js';
2
2
  import type { components as CellComponents } from './generated/cell-api.js';
3
+ import type { LifecycleTiming } from './progress.js';
3
4
  export type AppErrorBody = AppComponents['schemas']['ErrorBody'];
4
5
  export type AppErrorCode = AppErrorBody['error']['code'];
5
6
  export type CellErrorCode = CellComponents['schemas']['ErrorCode'];
6
7
  /** Closed error codes of the application API and the cell gateway (both published in OpenAPI). */
7
8
  export type ErrorCode = AppErrorCode | CellErrorCode;
9
+ /**
10
+ * `details.reason` values the SDK knows (the error code enum is closed; new cases add reasons). Templates v2
11
+ * (contracts §19.14) added: 409 conflict legacy_disk_layout, not_session, session_lifetime, lifetime_mismatch,
12
+ * not_resettable, template_not_layered, draft_exists, draft_stale, build_in_progress, file_list_unavailable,
13
+ * file_list_indexing (retryable), guest_feature_unavailable; 422 validation_failed confirm_destructive_required,
14
+ * reserved_key_prefix, invalid_defaults, update_policy_not_available, invalid_path, too_many_acknowledged_findings;
15
+ * 403 forbidden template_dev_mode_role; 404 not_found draft_not_found, version_not_found, path_not_found.
16
+ */
17
+ export type KnownErrorReason = 'workspace_not_running' | 'operation_in_progress' | 'workspace_deleted' | 'secret_not_available' | 'legacy_disk_layout' | 'not_session' | 'session_lifetime' | 'lifetime_mismatch' | 'not_resettable' | 'template_not_layered' | 'draft_exists' | 'draft_stale' | 'build_in_progress' | 'file_list_unavailable' | 'file_list_indexing' | 'guest_feature_unavailable' | 'confirm_destructive_required' | 'reserved_key_prefix' | 'invalid_defaults' | 'update_policy_not_available' | 'invalid_path' | 'too_many_acknowledged_findings' | 'template_dev_mode_role' | 'draft_not_found' | 'version_not_found' | 'path_not_found';
18
+ /** A known reason, or any other string the server sends (reasons are open-ended). */
19
+ export type ErrorReason = KnownErrorReason | (string & {});
8
20
  export interface ErrorBodyLike {
9
21
  error: {
10
22
  code: string;
@@ -26,6 +38,10 @@ export declare class ShardfluxApiError extends Error {
26
38
  readonly operationId: string | undefined;
27
39
  readonly source: 'api' | 'cell';
28
40
  readonly retryAfterSeconds: number | undefined;
41
+ /** `details.reason` when it is a string (e.g. `not_session`, `draft_not_found`, `legacy_disk_layout`). */
42
+ readonly reason: ErrorReason | undefined;
43
+ /** Where the time went when a traced call (open, a waited lifecycle call, a wake) failed with this error. */
44
+ timing: LifecycleTiming | undefined;
29
45
  constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number);
30
46
  }
31
47
  /** The response was not the documented shape (e.g. a proxy error page). */
@@ -45,6 +61,8 @@ export declare class OperationTimeoutError extends Error {
45
61
  readonly lastState: Operation['state'];
46
62
  readonly lastReason: string | null;
47
63
  readonly waitedMs: number;
64
+ /** Where the time went: client phases, retries and the operation's own server timing so far. */
65
+ timing: LifecycleTiming | undefined;
48
66
  constructor(op: Operation, waitedMs: number);
49
67
  }
50
68
  /** The operation reached `failed` or `canceled`. */
@@ -52,6 +70,8 @@ export declare class OperationFailedError extends Error {
52
70
  readonly operation: Operation;
53
71
  readonly operationId: string;
54
72
  readonly errorCode: string | null;
73
+ /** Where the time went before the operation failed. */
74
+ timing: LifecycleTiming | undefined;
55
75
  constructor(op: Operation);
56
76
  }
57
77
  export {};
package/dist/errors.js CHANGED
@@ -14,6 +14,10 @@ export class ShardfluxApiError extends Error {
14
14
  operationId;
15
15
  source;
16
16
  retryAfterSeconds;
17
+ /** `details.reason` when it is a string (e.g. `not_session`, `draft_not_found`, `legacy_disk_layout`). */
18
+ reason;
19
+ /** Where the time went when a traced call (open, a waited lifecycle call, a wake) failed with this error. */
20
+ timing = undefined;
17
21
  constructor(status, body, source, retryAfterSeconds) {
18
22
  super(body.error.message);
19
23
  this.name = 'ShardfluxApiError';
@@ -25,6 +29,8 @@ export class ShardfluxApiError extends Error {
25
29
  this.operationId = body.error.operation_id;
26
30
  this.source = source;
27
31
  this.retryAfterSeconds = retryAfterSeconds;
32
+ const reason = body.error.details?.reason;
33
+ this.reason = typeof reason === 'string' ? reason : undefined;
28
34
  }
29
35
  }
30
36
  /** The response was not the documented shape (e.g. a proxy error page). */
@@ -47,6 +53,8 @@ export class OperationTimeoutError extends Error {
47
53
  lastState;
48
54
  lastReason;
49
55
  waitedMs;
56
+ /** Where the time went: client phases, retries and the operation's own server timing so far. */
57
+ timing = undefined;
50
58
  constructor(op, waitedMs) {
51
59
  super(`Operation ${op.id} (${op.kind}) is still ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''} after ${Math.round(waitedMs)} ms; it continues server side.`);
52
60
  this.name = 'OperationTimeoutError';
@@ -62,6 +70,8 @@ export class OperationFailedError extends Error {
62
70
  operation;
63
71
  operationId;
64
72
  errorCode;
73
+ /** Where the time went before the operation failed. */
74
+ timing = undefined;
65
75
  constructor(op) {
66
76
  const code = typeof op.error?.code === 'string' ? op.error.code : null;
67
77
  super(`Operation ${op.id} (${op.kind}) ${op.state}${code ? `: ${code}` : ''}`);