@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/CHANGELOG.md +63 -0
- package/README.md +228 -24
- package/dist/cell.d.ts +67 -2
- package/dist/cell.js +98 -8
- package/dist/client.d.ts +148 -22
- package/dist/client.js +232 -29
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +10 -0
- package/dist/generated/app-api.d.ts +10223 -5857
- package/dist/generated/cell-api.d.ts +140 -8
- package/dist/http.d.ts +30 -1
- package/dist/http.js +57 -3
- package/dist/index.d.ts +13 -9
- package/dist/index.js +5 -4
- package/dist/lifecycle.d.ts +48 -0
- package/dist/lifecycle.js +33 -0
- package/dist/progress.d.ts +166 -0
- package/dist/progress.js +238 -0
- package/dist/secrets.d.ts +83 -6
- package/dist/secrets.js +53 -1
- package/dist/templates.d.ts +279 -7
- package/dist/templates.js +216 -4
- package/dist/tokens.d.ts +8 -3
- package/dist/tokens.js +25 -13
- package/dist/tools.d.ts +17 -0
- package/dist/tools.js +5 -1
- package/dist/workspace.d.ts +112 -20
- package/dist/workspace.js +176 -10
- package/package.json +2 -1
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
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
return
|
|
300
|
+
suspend(workspaceId, opts = {}) {
|
|
301
|
+
return this.#op('suspend', workspaceId, undefined, opts);
|
|
136
302
|
}
|
|
137
|
-
|
|
138
|
-
return
|
|
303
|
+
resume(workspaceId, opts = {}) {
|
|
304
|
+
return this.#op('resume', workspaceId, undefined, opts);
|
|
139
305
|
}
|
|
140
|
-
|
|
141
|
-
return
|
|
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
|
-
|
|
145
|
-
|
|
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 ??
|
|
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}` : ''}`);
|