@shardflux/sdk 0.5.0 → 0.6.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/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,125 @@ 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
+ const init = { json: body, idempotencyKey: params.idempotencyKey ?? randomId('open-'), onRetry: trace.onRetry };
85
+ const started = Date.now();
86
+ const timeoutMs = waitOpts?.timeoutMs ?? 300_000;
87
+ if (waitOpts && waitOpts.serverWait !== false) {
88
+ // Held open (contracts §22.3): the server answers once the operation is terminal (200 with the running workspace
89
+ // and a tool token) or the wait elapsed (202); a server without it answers 202 at once and the poll below runs.
90
+ const s = Math.min(SERVER_WAIT_MAX_S, Math.floor(timeoutMs / 1000));
91
+ if (s >= 1) {
92
+ init.headers = { prefer: `wait=${s}` };
93
+ init.timeoutMs = Math.max(this.#http.opts.timeoutMs, s * 1000 + 10_000);
94
+ }
95
+ this.#preconnect();
96
+ }
97
+ const held = init.headers?.prefer !== undefined;
98
+ trace.phase('request', held ? 'held' : null);
99
+ const res = await this.#http.jsonWithStatus('POST', '/v1/workspaces/open', init, this.#auth);
100
+ trace.workspaceId = res.body.workspace.id;
101
+ if (res.body.operation)
102
+ trace.observe(res.body.operation);
103
+ this.#noteToken(res.body.tool_token);
104
+ const wrapOpts = { agentLabel: params.agentLabel, tools: params.tools, token: res.body.tool_token, trace };
105
+ if (res.status === 200 || params.wait === false || res.body.operation === null)
106
+ return this.#wrap(res.body.workspace, wrapOpts);
107
+ if (TERMINAL.has(res.body.operation.state)) {
108
+ if (res.body.operation.state !== 'succeeded')
109
+ throw new OperationFailedError(res.body.operation);
110
+ }
111
+ else {
112
+ // A held open already spent part of the budget; without the preference the budget is the poll's (as before).
113
+ const wait = { ...(params.wait ?? {}), ...(held ? { timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)) } : {}), [TRACE]: trace };
114
+ await this.waitForOperation(res.body.operation.id, wait);
115
+ }
116
+ // Ready: read the view and issue the first tool token in parallel (a 200 open returns both at once). A token
117
+ // failure (e.g. a key without tool permissions) is left to the first tool call, which fetches and reports it.
118
+ const id = res.body.workspace.id;
119
+ const [view, token] = await Promise.all([
120
+ trace.span('view', () => this.#getView(id, trace.onRetry)),
121
+ trace.span('token', () => this.#issueToken(id, params.agentLabel, params.tools, trace.onRetry)),
122
+ ]);
123
+ return this.#wrap(view, { ...wrapOpts, token });
124
+ });
125
+ }
126
+ #noteToken(token) {
127
+ if (token?.cell_endpoint)
128
+ this.#cellHint = token.cell_endpoint;
59
129
  }
60
130
  /**
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).
131
+ * Opens a connection to the last known cell endpoint in the background (GET /healthz, body discarded), so the
132
+ * first tool call after the open reuses it instead of paying the TCP and TLS handshakes. Best effort: errors are
133
+ * ignored, and a fetch that closes connections (Connection: close) simply gains nothing.
134
+ */
135
+ #preconnect() {
136
+ const hint = this.#cellHint;
137
+ if (!hint)
138
+ return;
139
+ const { fetch: f, userAgent } = this.#ctx();
140
+ let url;
141
+ try {
142
+ url = new URL('/healthz', hint).toString();
143
+ }
144
+ catch {
145
+ return;
146
+ }
147
+ void f(url, { method: 'GET', headers: { 'user-agent': userAgent }, signal: AbortSignal.timeout(5_000) })
148
+ .then((r) => r.body?.cancel())
149
+ .catch(() => undefined);
150
+ }
151
+ async #issueToken(workspaceId, agentLabel, tools, onRetry) {
152
+ const body = {};
153
+ if (agentLabel !== undefined)
154
+ body.agent_label = agentLabel;
155
+ if (tools !== undefined)
156
+ body.tools = [...tools];
157
+ try {
158
+ const token = await this.#http.json('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/tool-tokens`, { json: body, ...(onRetry ? { onRetry } : {}) }, this.#auth);
159
+ this.#noteToken(token);
160
+ return token;
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ /**
167
+ * Waits for an operation: each GET /v1/operations/{id} asks the server to hold the response until the state changes
168
+ * (`Prefer: wait`, at most 20 s, contracts §3), so completion is seen within one notification of the commit. A server
169
+ * that does not wait is polled with bounded exponential backoff (+-20 % jitter). Resolves when the operation
170
+ * succeeds; throws OperationFailedError when it fails or is canceled, OperationTimeoutError after `timeoutMs` (the
171
+ * operation keeps running and can be awaited again). Both errors carry the wait's `timing`; `onProgress` sees each
172
+ * state change (queued, capacity_pending, running, with the server's reason) as it is observed.
64
173
  */
65
174
  async waitForOperation(operationId, opts = {}) {
66
- const { sleep } = this.#ctx();
175
+ const inherited = opts[TRACE];
176
+ const trace = inherited ?? new Trace('wait', combineListeners(this.#ctx().onProgress, opts.onProgress), { operationId });
177
+ const run = () => this.#poll(operationId, opts, trace);
178
+ return inherited ? run() : traced(trace, run);
179
+ }
180
+ async #poll(operationId, opts, trace) {
181
+ const { sleep, http, authorization } = this.#ctx();
67
182
  const timeoutMs = opts.timeoutMs ?? 300_000;
68
183
  const maxInterval = opts.maxPollIntervalMs ?? 5_000;
69
184
  let interval = opts.pollIntervalMs ?? 250;
70
185
  const started = Date.now();
71
186
  const aborted = () => (opts.signal?.reason instanceof Error ? opts.signal.reason : new Error('aborted'));
187
+ let lastKey = null;
72
188
  for (;;) {
73
189
  if (opts.signal?.aborted)
74
190
  throw aborted();
75
- const operation = await this.getOperation(operationId, opts.signal ? { signal: opts.signal } : {});
191
+ const waitS = opts.serverWait === false ? 0 : (timeoutMs - (Date.now() - started)) / 1000;
192
+ const t0 = Date.now();
193
+ const { body, applied } = await pollWithWait(http, `/v1/operations/${encodeURIComponent(operationId)}`, authorization, waitS, opts.signal, trace.onRetry);
194
+ const operation = body.operation;
195
+ trace.observe(operation);
76
196
  if (operation.state === 'succeeded')
77
197
  return operation;
78
198
  if (TERMINAL.has(operation.state))
@@ -80,6 +200,13 @@ export class WorkspacesApi {
80
200
  const waited = Date.now() - started;
81
201
  if (waited >= timeoutMs)
82
202
  throw new OperationTimeoutError(operation, waited);
203
+ const key = `${operation.state}/${operation.state_reason ?? ''}`;
204
+ // The server held the poll (or answered a change): ask again at once. An applied wait that returned quickly with
205
+ // no change falls through to the backoff, so a misbehaving server can never make this loop spin.
206
+ const changed = key !== lastKey;
207
+ lastKey = key;
208
+ if (applied && (changed || Date.now() - t0 >= 1_000))
209
+ continue;
83
210
  const jitter = interval * 0.2 * (Math.random() * 2 - 1);
84
211
  const delay = Math.max(10, Math.min(interval + jitter, timeoutMs - waited));
85
212
  // The wait itself is abortable: a signal ends it at once instead of after the sleep.
@@ -94,8 +221,8 @@ export class WorkspacesApi {
94
221
  const { operation } = await this.#http.json('GET', `/v1/operations/${encodeURIComponent(operationId)}`, opts.signal ? { signal: opts.signal } : {}, this.#auth);
95
222
  return operation;
96
223
  }
97
- async #getView(workspaceId) {
98
- return this.#http.json('GET', `/v1/workspaces/${encodeURIComponent(workspaceId)}`, {}, this.#auth);
224
+ async #getView(workspaceId, onRetry) {
225
+ return this.#http.json('GET', `/v1/workspaces/${encodeURIComponent(workspaceId)}`, onRetry ? { onRetry } : {}, this.#auth);
99
226
  }
100
227
  async get(workspaceId, opts = {}) {
101
228
  return this.#wrap(await this.#getView(workspaceId), opts);
@@ -109,6 +236,8 @@ export class WorkspacesApi {
109
236
  project_id: params.projectId,
110
237
  organization_id: params.organizationId,
111
238
  include_deleted: params.includeDeleted,
239
+ lifetime: params.lifetime,
240
+ purpose: params.purpose,
112
241
  limit: params.limit,
113
242
  cursor: params.cursor,
114
243
  },
@@ -124,25 +253,98 @@ export class WorkspacesApi {
124
253
  cursor = page.nextCursor ?? undefined;
125
254
  } while (cursor);
126
255
  }
127
- async #lifecycle(method, path, json, idempotencyKey) {
128
- return this.#http.json(method, path, { ...(json === undefined ? {} : { json }), idempotencyKey: idempotencyKey ?? randomId('op-') }, this.#auth);
256
+ /**
257
+ * Looks a workspace up by its exact key across every lifetime and purpose (`lifetime=any&purpose=any`), preferring the
258
+ * live workspace over tombstones of ended sessions or deleted workspaces with the same key (see pickByKey). Null when
259
+ * no workspace of the project has the key. This is the lookup the CLI and the MCP server use.
260
+ */
261
+ async findByKey(key, opts = {}) {
262
+ const includeDeleted = opts.includeDeleted !== false;
263
+ const seen = [];
264
+ let cursor;
265
+ do {
266
+ if (opts.signal?.aborted)
267
+ throw opts.signal.reason instanceof Error ? opts.signal.reason : new Error('aborted');
268
+ const page = await this.#http.json('GET', '/v1/workspaces', {
269
+ query: {
270
+ key_prefix: key,
271
+ lifetime: 'any',
272
+ purpose: 'any',
273
+ include_deleted: includeDeleted,
274
+ project_id: opts.projectId,
275
+ organization_id: opts.organizationId,
276
+ limit: 200,
277
+ cursor,
278
+ },
279
+ ...(opts.signal ? { signal: opts.signal } : {}),
280
+ }, this.#auth);
281
+ const live = page.data.find((v) => v.workspace_key === key && v.deleted_at === null);
282
+ if (live)
283
+ return this.#wrap(live, { agentLabel: opts.agentLabel, tools: opts.tools });
284
+ seen.push(...page.data.filter((v) => v.workspace_key === key));
285
+ cursor = page.next_cursor ?? undefined;
286
+ } while (cursor);
287
+ const hit = pickByKey(seen, key);
288
+ return hit ? this.#wrap(hit, { agentLabel: opts.agentLabel, tools: opts.tools }) : null;
289
+ }
290
+ async #lifecycle(method, path, json, idempotencyKey, init = {}) {
291
+ return this.#http.json(method, path, { ...(json === undefined ? {} : { json }), idempotencyKey: idempotencyKey ?? randomId('op-'), ...init }, this.#auth);
292
+ }
293
+ #op(kind, workspaceId, json, opts) {
294
+ const path = `/v1/workspaces/${encodeURIComponent(workspaceId)}${kind === 'delete' ? '' : `/${kind}`}`;
295
+ return runLifecycle(this.#ctx(), kind, workspaceId, async (init) => (await this.#lifecycle(kind === 'delete' ? 'DELETE' : 'POST', path, json, opts.idempotencyKey, init)).operation, opts);
129
296
  }
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;
297
+ delete(workspaceId, opts = {}) {
298
+ return this.#op('delete', workspaceId, undefined, opts);
133
299
  }
134
- async suspend(workspaceId, opts = {}) {
135
- return (await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/suspend`, undefined, opts.idempotencyKey)).operation;
300
+ suspend(workspaceId, opts = {}) {
301
+ return this.#op('suspend', workspaceId, undefined, opts);
136
302
  }
137
- async resume(workspaceId, opts = {}) {
138
- return (await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/resume`, undefined, opts.idempotencyKey)).operation;
303
+ resume(workspaceId, opts = {}) {
304
+ return this.#op('resume', workspaceId, undefined, opts);
139
305
  }
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;
306
+ snapshot(workspaceId, opts = {}) {
307
+ return this.#op('snapshot', workspaceId, opts.label === undefined ? {} : { label: opts.label }, opts);
308
+ }
309
+ async close(workspaceId, opts = {}) {
310
+ return (await this.closeWithView(workspaceId, opts)).operation;
311
+ }
312
+ /** close() plus the workspace view after the close (a tombstone). */
313
+ async closeWithView(workspaceId, opts = {}) {
314
+ let workspace;
315
+ const operation = await runLifecycle(this.#ctx(), 'close', workspaceId, async (init) => {
316
+ const out = await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/close`, undefined, opts.idempotencyKey, init);
317
+ workspace = out.workspace;
318
+ return out.operation;
319
+ }, opts);
320
+ return { operation, workspace: workspace };
321
+ }
322
+ reset(workspaceId, opts = {}) {
323
+ const body = { confirm_destructive: true };
324
+ return this.#op('reset', workspaceId, body, opts);
325
+ }
326
+ /**
327
+ * Saves a layered workspace as the next version of an organization template (contracts §19.8). A running workspace is
328
+ * captured briefly (`operation`, layer_snapshot); poll `build` with templates.builds.waitForBuild. Owners/admins and
329
+ * API keys with a tool permission only.
330
+ */
331
+ saveAsTemplate(workspaceId, params) {
332
+ return this.#http.json('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/save-as-template`, { json: saveAsTemplateBody(params), idempotencyKey: params.idempotencyKey ?? randomId('save-') }, this.#auth);
142
333
  }
143
334
  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) };
335
+ let copy;
336
+ const operation = await runLifecycle(this.#ctx(), 'fork', workspaceId, async (init) => {
337
+ const out = await this.#lifecycle('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/fork`, target, opts.idempotencyKey, init);
338
+ copy = this.#wrap(out.workspace);
339
+ return out.operation;
340
+ }, {
341
+ ...opts,
342
+ [AFTER_WAIT]: async (trace, op) => {
343
+ await trace.span('view', () => copy.refresh());
344
+ await opts[AFTER_WAIT]?.(trace, op);
345
+ },
346
+ });
347
+ return { operation, workspace: copy };
146
348
  }
147
349
  async operations(workspaceId, params = {}) {
148
350
  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 +405,7 @@ export class Shardflux {
203
405
  constructor(opts) {
204
406
  if (!/^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/.test(opts.apiKey))
205
407
  throw new Error('apiKey must be a Shardflux project key (sfk_<key_id>_<secret>)');
206
- const f = opts.fetch ?? fetch;
408
+ const f = opts.fetch ?? defaultFetch();
207
409
  const userAgent = opts.userAgent ?? `shardflux-sdk-ts/${SDK_VERSION}`;
208
410
  const sleep = opts.sleep ?? defaultSleep;
209
411
  this.workspaces = new WorkspacesApi(() => this.#ctx);
@@ -221,6 +423,7 @@ export class Shardflux {
221
423
  userAgent,
222
424
  sleep,
223
425
  workspaces: this.workspaces,
426
+ onProgress: opts.onProgress,
224
427
  };
225
428
  }
226
429
  /** 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}` : ''}`);