@shardflux/sdk 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +258 -0
- package/README.md +338 -7
- package/dist/account.d.ts +493 -0
- package/dist/account.js +641 -0
- package/dist/cell.d.ts +203 -8
- package/dist/cell.js +457 -32
- package/dist/client.d.ts +120 -5
- package/dist/client.js +142 -6
- package/dist/errors.d.ts +90 -3
- package/dist/errors.js +93 -1
- package/dist/executions.d.ts +120 -0
- package/dist/executions.js +99 -0
- package/dist/feedback.d.ts +67 -0
- package/dist/feedback.js +39 -0
- package/dist/generated/app-api.d.ts +10546 -5855
- package/dist/generated/cell-api.d.ts +501 -9
- package/dist/http.d.ts +7 -1
- package/dist/http.js +26 -7
- package/dist/index.d.ts +18 -7
- package/dist/index.js +5 -1
- package/dist/lifecycle.d.ts +27 -2
- package/dist/lifecycle.js +5 -0
- package/dist/progress.js +4 -2
- package/dist/templates.js +2 -2
- package/dist/tools.d.ts +28 -1
- package/dist/tools.js +172 -21
- package/dist/usage.d.ts +36 -6
- package/dist/usage.js +19 -4
- package/dist/version-check.d.ts +101 -0
- package/dist/version-check.js +191 -0
- package/dist/workspace.d.ts +100 -6
- package/dist/workspace.js +198 -12
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { components, operations } from './generated/app-api.js';
|
|
11
11
|
import { ShardfluxApiError } from './errors.js';
|
|
12
|
+
import type { WorkspaceMode } from './errors.js';
|
|
12
13
|
import { HttpClient } from './http.js';
|
|
13
|
-
import type {
|
|
14
|
+
import type { RequestOptions } from './http.js';
|
|
15
|
+
import type { ToolName, ToolToken } from './tokens.js';
|
|
14
16
|
import { Workspace } from './workspace.js';
|
|
15
17
|
import { AuditApi } from './audit.js';
|
|
16
18
|
import { EgressPolicyApi } from './egress.js';
|
|
@@ -19,9 +21,11 @@ import { TemplatesApi } from './templates.js';
|
|
|
19
21
|
import type { SaveAsTemplateParams, SaveAsTemplateResponse } from './templates.js';
|
|
20
22
|
import { UsageApi } from './usage.js';
|
|
21
23
|
import { VolumesApi } from './volumes.js';
|
|
22
|
-
import type {
|
|
24
|
+
import type { VersionCheckOption } from './version-check.js';
|
|
25
|
+
import type { FinishedOperation, InternalLifecycleOptions, LifecycleOptions, ResumeOptions, WaitedLifecycleOptions, WaitedResumeOptions } from './lifecycle.js';
|
|
23
26
|
import type { ProgressListener } from './progress.js';
|
|
24
27
|
import { CaptureRegistry } from './capture.js';
|
|
28
|
+
import type { FeedbackReceipt, SendFeedbackParams } from './feedback.js';
|
|
25
29
|
export type WorkspaceView = components['schemas']['Workspace'];
|
|
26
30
|
export type Operation = components['schemas']['Operation'];
|
|
27
31
|
/** persistent (kept until deleted) or session (discarded when the session ends: close(), idle timeout; contracts §19.11). */
|
|
@@ -52,6 +56,32 @@ type Ok<Op> = Op extends {
|
|
|
52
56
|
[K in keyof R]: K extends 200 | 201 | 202 ? JsonOf<R[K]> : never;
|
|
53
57
|
}[keyof R] : never;
|
|
54
58
|
export type OpenResponse = Ok<operations['postV1WorkspacesOpen']>;
|
|
59
|
+
/** POST /v1/workspaces/{id}/resume: the held 200 (contracts §22.6) or the lifecycle 202. */
|
|
60
|
+
export type ResumeResponse = Ok<operations['postV1WorkspacesWorkspaceIdResume']>;
|
|
61
|
+
/** What a resume request answered (0.9.0; `WorkspacesApi.requestResume`). */
|
|
62
|
+
export interface ResumeAnswer {
|
|
63
|
+
/**
|
|
64
|
+
* True for a held resume that ended with the workspace running (200 with `Preference-Applied`): `workspace` and
|
|
65
|
+
* `toolToken` are current. False for any other answer: wait for `operation` (an older server answers 202 at once).
|
|
66
|
+
*/
|
|
67
|
+
ready: boolean;
|
|
68
|
+
/** The resume, or the open/resume it joined; null when a held resume found the workspace already running. */
|
|
69
|
+
operation: Operation | null;
|
|
70
|
+
workspace: WorkspaceView;
|
|
71
|
+
/** The tool token of a ready answer (null when the API could not issue one: the first tool call fetches it). */
|
|
72
|
+
toolToken: ToolToken | null;
|
|
73
|
+
requestId: string | null;
|
|
74
|
+
}
|
|
75
|
+
export interface ResumeRequestOptions {
|
|
76
|
+
/** Seconds the server may hold the request (`Prefer: wait`, at most 20); below 1 the request is not held. */
|
|
77
|
+
waitS: number;
|
|
78
|
+
/** The tool token a held resume returns: agent label and tools (default all the key's tools, label `default`). */
|
|
79
|
+
agentLabel?: string | undefined;
|
|
80
|
+
tools?: readonly ToolName[] | undefined;
|
|
81
|
+
idempotencyKey?: string | undefined;
|
|
82
|
+
signal?: AbortSignal | undefined;
|
|
83
|
+
onRetry?: RequestOptions['onRetry'] | undefined;
|
|
84
|
+
}
|
|
55
85
|
export type AgentSession = Ok<operations['getV1WorkspacesWorkspaceIdAgentSessions']>['data'][number];
|
|
56
86
|
export type Entitlements = Ok<operations['getV1OrganizationsOrganizationIdEntitlements']>;
|
|
57
87
|
export type Me = Ok<operations['getV1Me']>;
|
|
@@ -64,6 +94,28 @@ export type CheckoutSession = Ok<operations['postApiV1OrganizationsOrganizationI
|
|
|
64
94
|
export type PortalSession = Ok<operations['postApiV1OrganizationsOrganizationIdBillingPortalSessions']>;
|
|
65
95
|
export type InvoicePage = Ok<operations['getApiV1OrganizationsOrganizationIdBillingInvoices']>;
|
|
66
96
|
export type Invoice = InvoicePage['data'][number];
|
|
97
|
+
/**
|
|
98
|
+
* A pending suspend-when-idle request (0.10.0; contracts §20.6): once the workspace has been idle for `after_seconds`
|
|
99
|
+
* (counted from the later of its last work and `requested_at`), it is suspended; `not_before` = requested_at +
|
|
100
|
+
* after_seconds is the earliest.
|
|
101
|
+
*/
|
|
102
|
+
export type SuspendRequest = components['schemas']['SuspendRequest'];
|
|
103
|
+
/** POST /v1/workspaces/{id}/suspend-when-idle (202). */
|
|
104
|
+
export type SuspendWhenIdleResponse = Ok<operations['postV1WorkspacesWorkspaceIdSuspendWhenIdle']>;
|
|
105
|
+
export interface SuspendWhenIdleOptions {
|
|
106
|
+
/** Seconds the workspace must stay idle before it is suspended: an integer from 30 to 3600 (the API refuses others with 422 validation_failed). */
|
|
107
|
+
afterSeconds: number;
|
|
108
|
+
/** Replays the stored response for a repeated request (default: a fresh key per call, so transport retries replay). */
|
|
109
|
+
idempotencyKey?: string;
|
|
110
|
+
}
|
|
111
|
+
export interface SuspendWhenIdleResult {
|
|
112
|
+
/** The recorded request; null when a suspend was already in progress (then `operation` is that suspend). */
|
|
113
|
+
suspendRequest: SuspendRequest | null;
|
|
114
|
+
/** The suspend already in progress (nothing was recorded), else null: the suspend itself happens later, from the cell's idle loop. */
|
|
115
|
+
operation: Operation | null;
|
|
116
|
+
/** The workspace after the call (on a handle: the handle itself, with its view updated). */
|
|
117
|
+
workspace: Workspace;
|
|
118
|
+
}
|
|
67
119
|
export interface ShardfluxOptions {
|
|
68
120
|
/** Project API key: sfk_<key_id>_<secret>. */
|
|
69
121
|
apiKey: string;
|
|
@@ -83,6 +135,13 @@ export interface ShardfluxOptions {
|
|
|
83
135
|
* retries and busy waits of tool calls): for logs or telemetry. Per-call `onProgress` listeners get their own events too.
|
|
84
136
|
*/
|
|
85
137
|
onProgress?: ProgressListener;
|
|
138
|
+
/**
|
|
139
|
+
* The automatic version check (0.9.0; contracts §30.4): after the first successful API response of the process, a
|
|
140
|
+
* background GET /v1/client-versions (3 s timeout, errors swallowed) emits a `ShardfluxUpdateWarning` when this
|
|
141
|
+
* package is outdated or unsupported. Default true (`@shardflux/sdk` at SDK_VERSION); tools built on the SDK pass
|
|
142
|
+
* their own `{ package, version }` or `false`. `SHARDFLUX_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1` turn it off.
|
|
143
|
+
*/
|
|
144
|
+
versionCheck?: VersionCheckOption;
|
|
86
145
|
}
|
|
87
146
|
export interface Caps {
|
|
88
147
|
cpu_millis?: number;
|
|
@@ -144,6 +203,16 @@ export interface OpenParams {
|
|
|
144
203
|
* with another value is ShardfluxApiError 409 (details.reason `lifetime_mismatch`).
|
|
145
204
|
*/
|
|
146
205
|
lifetime?: WorkspaceLifetime;
|
|
206
|
+
/**
|
|
207
|
+
* `file_first` (0.9.0; contracts §29): the workspace is a versioned file tree with no VM between executions. It is
|
|
208
|
+
* ready at once (no operation: the open answers with a tool token), never suspended, and runs commands as executions
|
|
209
|
+
* (`workspace.executions.run()`): a fresh VM on the latest tree whose changed files become the next tree revision;
|
|
210
|
+
* nothing else survives an execution. Needs a layered template version (409 `layout_unsupported` otherwise) and is
|
|
211
|
+
* persistent (`lifetime: 'session'` with it is 422 `not_supported_for_mode`). Omitted: `processful` for a new key, the
|
|
212
|
+
* stored mode for an existing one. Immutable: reopening a key with another mode is 409 `mode_mismatch`; 422
|
|
213
|
+
* `mode_not_available` while the deployment does not offer file-first workspaces.
|
|
214
|
+
*/
|
|
215
|
+
mode?: WorkspaceMode;
|
|
147
216
|
/** `false`: return immediately (possibly not ready). Default: wait until ready. */
|
|
148
217
|
wait?: false | WaitOptions;
|
|
149
218
|
/** Defaults to a fresh key per open() call so transport retries replay instead of duplicating. */
|
|
@@ -252,9 +321,24 @@ export declare class WorkspacesApi {
|
|
|
252
321
|
*/
|
|
253
322
|
suspend(workspaceId: string, opts: WaitedLifecycleOptions): Promise<FinishedOperation>;
|
|
254
323
|
suspend(workspaceId: string, opts?: LifecycleOptions): Promise<Operation>;
|
|
255
|
-
/**
|
|
256
|
-
|
|
257
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Resumes a suspended workspace. Resolves when the resume is requested; with `wait`, once the workspace runs. With
|
|
326
|
+
* `wait` (0.9.0) the request is held by the server until the workspace runs (contracts §22.6: one request, timing phase
|
|
327
|
+
* `request` with reason `held`); a server that does not hold it answers at once and the operation is polled.
|
|
328
|
+
* `serverWait: false` polls only. `agentLabel`/`tools` choose the tool token the held answer carries (attribution
|
|
329
|
+
* only here; `workspace.resume()` keeps it). A workspace that is already running is ShardfluxApiError 409 `conflict`
|
|
330
|
+
* (details.reason `already_running`), with or without `wait`.
|
|
331
|
+
*/
|
|
332
|
+
resume(workspaceId: string, opts: WaitedResumeOptions): Promise<FinishedOperation>;
|
|
333
|
+
resume(workspaceId: string, opts?: ResumeOptions): Promise<Operation>;
|
|
334
|
+
/**
|
|
335
|
+
* One `POST /v1/workspaces/{id}/resume` (0.9.0), held for up to `waitS` seconds when that is at least 1 (`Prefer:
|
|
336
|
+
* wait`, contracts §22.6), asking for the tool token of `agentLabel`/`tools`. `ready` only for a 200 the server says it
|
|
337
|
+
* held (`Preference-Applied`); every other answer is the operation to wait for, so a server without the held resume
|
|
338
|
+
* works unchanged. Refusals (409 operation_in_progress, already_running without the hold, ...) throw as usual.
|
|
339
|
+
* `Workspace.wake()` and `resume({ wait })` use it; most callers want those.
|
|
340
|
+
*/
|
|
341
|
+
requestResume(workspaceId: string, o: ResumeRequestOptions): Promise<ResumeAnswer>;
|
|
258
342
|
/** Takes a snapshot. Resolves when it is requested; with `wait`, once it is taken. */
|
|
259
343
|
snapshot(workspaceId: string, opts: WaitedLifecycleOptions & {
|
|
260
344
|
label?: string;
|
|
@@ -262,6 +346,28 @@ export declare class WorkspacesApi {
|
|
|
262
346
|
snapshot(workspaceId: string, opts?: LifecycleOptions & {
|
|
263
347
|
label?: string;
|
|
264
348
|
}): Promise<Operation>;
|
|
349
|
+
/**
|
|
350
|
+
* Suspends the workspace once it has been idle for `afterSeconds` (0.9.0; contracts §20.6): meant for the end of an
|
|
351
|
+
* agent turn, so the workspace stops using RAM soon after instead of waiting out its idle policy. The idle time
|
|
352
|
+
* counts from the later of the workspace's last work and this request. A command still running, an attached exec or
|
|
353
|
+
* terminal stream, or a keepalive postpones the suspend until `afterSeconds` after it ends; a tool call after the
|
|
354
|
+
* request (the next turn) or a resume cancels it. Repeating replaces the pending request. It applies under every idle
|
|
355
|
+
* policy (`never` included) and never delays a suspend the policy would do sooner.
|
|
356
|
+
*
|
|
357
|
+
* Resolves with the recorded `suspendRequest`, or, when a suspend is already in progress, with that `operation` and
|
|
358
|
+
* nothing recorded. Tool-call capture writes recorded before the call land first (a later write would count as the
|
|
359
|
+
* next turn). Errors: ShardfluxApiError 409 with reason `not_running`, `operation_in_progress`, `session_lifetime` or
|
|
360
|
+
* `workspace_deleted`; 422 `validation_failed` for `afterSeconds` outside 30..3600.
|
|
361
|
+
*
|
|
362
|
+
* await cloud.workspaces.suspendWhenIdle(workspace.id, { afterSeconds: 60 });
|
|
363
|
+
*/
|
|
364
|
+
suspendWhenIdle(workspaceId: string, opts: SuspendWhenIdleOptions): Promise<SuspendWhenIdleResult>;
|
|
365
|
+
/**
|
|
366
|
+
* Cancels a pending suspend-when-idle request (0.10.0; DELETE /v1/workspaces/{id}/suspend-when-idle). Idempotent,
|
|
367
|
+
* in any workspace state; resolves with the workspace (`suspendRequest` null). A suspend the request already started
|
|
368
|
+
* is not undone: it is the workspace's `activeOperation` (resume or open the workspace instead).
|
|
369
|
+
*/
|
|
370
|
+
cancelSuspendWhenIdle(workspaceId: string): Promise<Workspace>;
|
|
265
371
|
/**
|
|
266
372
|
* Ends a session workspace now (contracts §19.11): the workspace is deleted exactly like delete() (ended_reason
|
|
267
373
|
* closed) and returns the `delete` operation (input.reason session_closed); the key then opens a NEW workspace.
|
|
@@ -354,6 +460,15 @@ export declare class Shardflux {
|
|
|
354
460
|
/** The authenticated principal (the API key, its organization and project). */
|
|
355
461
|
me(): Promise<Me>;
|
|
356
462
|
entitlements(organizationId: string): Promise<Entitlements>;
|
|
463
|
+
/**
|
|
464
|
+
* Send feedback straight to the Shardflux founder (0.9.0+; POST /v1/feedback), who reads every message. Use it while
|
|
465
|
+
* you work, the moment something fails unexpectedly, an error or doc is confusing, something is missing or slow, or a
|
|
466
|
+
* workaround was needed; short and specific beats polished, and `context.requestId` / `errorCode` let the founder find
|
|
467
|
+
* the logs. `context.client` defaults to `shardflux-sdk-ts/<SDK_VERSION>`. Returns `{ id, receivedAt, duplicate }`
|
|
468
|
+
* (`duplicate`: the same message from this key within 24 hours; no second email). 429 `rate_limited` (with
|
|
469
|
+
* `retryAfterSeconds`) and 422 `validation_failed` are ShardfluxApiErrors; the call is never retried.
|
|
470
|
+
*/
|
|
471
|
+
sendFeedback(params: SendFeedbackParams): Promise<FeedbackReceipt>;
|
|
357
472
|
/** Raw access to any /v1 endpoint with the SDK's authentication and error handling. */
|
|
358
473
|
request<T>(method: string, path: string, init?: Parameters<HttpClient['json']>[2]): Promise<T>;
|
|
359
474
|
}
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { OperationFailedError, OperationTimeoutError, ShardfluxApiError } from "./errors.js";
|
|
1
|
+
import { OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
|
|
2
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";
|
|
@@ -7,9 +7,11 @@ import { SecretsApi } from "./secrets.js";
|
|
|
7
7
|
import { TemplatesApi, saveAsTemplateBody } from "./templates.js";
|
|
8
8
|
import { UsageApi } from "./usage.js";
|
|
9
9
|
import { VolumesApi } from "./volumes.js";
|
|
10
|
-
import {
|
|
10
|
+
import { versionCheckHook } from "./version-check.js";
|
|
11
|
+
import { AFTER_WAIT, HELD_RESUME, TRACE, runLifecycle, waitOptionsOf } from "./lifecycle.js";
|
|
11
12
|
import { Trace, combineListeners, traced } from "./progress.js";
|
|
12
13
|
import { CaptureRegistry } from "./capture.js";
|
|
14
|
+
import { sendFeedback } from "./feedback.js";
|
|
13
15
|
/**
|
|
14
16
|
* The workspace a key names (contracts §19.11): the live row (deleted_at null) when there is one, since at most one live
|
|
15
17
|
* workspace holds a key; otherwise the newest tombstone (ended sessions leave tombstones with the same key, and a
|
|
@@ -29,6 +31,21 @@ export function pickByKey(rows, key) {
|
|
|
29
31
|
return tomb;
|
|
30
32
|
}
|
|
31
33
|
const TERMINAL = new Set(['succeeded', 'failed', 'canceled']);
|
|
34
|
+
/**
|
|
35
|
+
* `resume({ wait })` of a workspace that is already running: a held resume answers 200 without an operation (contracts
|
|
36
|
+
* §22.6); the SDK reports it as the API does without the hold, 409 `conflict` `already_running`.
|
|
37
|
+
*/
|
|
38
|
+
function alreadyRunning(answer) {
|
|
39
|
+
return new ShardfluxApiError(409, {
|
|
40
|
+
error: {
|
|
41
|
+
code: 'conflict',
|
|
42
|
+
message: 'Only a suspended workspace can be resumed; this one is already running.',
|
|
43
|
+
request_id: answer.requestId ?? '',
|
|
44
|
+
retryable: false,
|
|
45
|
+
details: { reason: 'already_running', observed_state: answer.workspace.observed_state, desired_state: answer.workspace.desired_state },
|
|
46
|
+
},
|
|
47
|
+
}, 'api');
|
|
48
|
+
}
|
|
32
49
|
/** Races the injected sleep against the signal (the injected sleep itself may not be abortable). */
|
|
33
50
|
function abortableSleep(sleep, ms, signal) {
|
|
34
51
|
if (signal.aborted)
|
|
@@ -81,6 +98,8 @@ export class WorkspacesApi {
|
|
|
81
98
|
body.inputs = params.inputs;
|
|
82
99
|
if (params.lifetime !== undefined)
|
|
83
100
|
body.lifetime = params.lifetime;
|
|
101
|
+
if (params.mode !== undefined)
|
|
102
|
+
body.mode = params.mode;
|
|
84
103
|
const waitOpts = params.wait === false ? null : (params.wait ?? {});
|
|
85
104
|
const trace = new Trace('open', combineListeners(this.#ctx().onProgress, params.onProgress, waitOpts?.onProgress));
|
|
86
105
|
return traced(trace, async () => {
|
|
@@ -188,7 +207,7 @@ export class WorkspacesApi {
|
|
|
188
207
|
return traced(trace, run);
|
|
189
208
|
}
|
|
190
209
|
async #poll(operationId, opts, trace) {
|
|
191
|
-
const { sleep, http
|
|
210
|
+
const { sleep, http } = this.#ctx();
|
|
192
211
|
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
193
212
|
const maxInterval = opts.maxPollIntervalMs ?? 5_000;
|
|
194
213
|
let interval = opts.pollIntervalMs ?? 250;
|
|
@@ -200,7 +219,8 @@ export class WorkspacesApi {
|
|
|
200
219
|
throw aborted();
|
|
201
220
|
const waitS = opts.serverWait === false ? 0 : (timeoutMs - (Date.now() - started)) / 1000;
|
|
202
221
|
const t0 = Date.now();
|
|
203
|
-
|
|
222
|
+
// Read per poll: a user session (ShardfluxAccount) may rotate its token while the wait runs.
|
|
223
|
+
const { body, applied } = await pollWithWait(http, `/v1/operations/${encodeURIComponent(operationId)}`, this.#ctx().authorization, waitS, opts.signal, trace.onRetry);
|
|
204
224
|
const operation = body.operation;
|
|
205
225
|
trace.observe(operation);
|
|
206
226
|
if (operation.state === 'succeeded')
|
|
@@ -317,11 +337,115 @@ export class WorkspacesApi {
|
|
|
317
337
|
return this.#op('suspend', workspaceId, undefined, opts);
|
|
318
338
|
}
|
|
319
339
|
resume(workspaceId, opts = {}) {
|
|
320
|
-
|
|
340
|
+
const waitOpts = waitOptionsOf(opts);
|
|
341
|
+
if (!waitOpts || waitOpts.serverWait === false)
|
|
342
|
+
return this.#op('resume', workspaceId, undefined, opts);
|
|
343
|
+
const trace = new Trace('resume', combineListeners(this.#ctx().onProgress, opts.onProgress, waitOpts.onProgress), { workspaceId });
|
|
344
|
+
return traced(trace, async () => {
|
|
345
|
+
const timeoutMs = waitOpts.timeoutMs ?? 300_000;
|
|
346
|
+
const started = Date.now();
|
|
347
|
+
// A handle's target takes the answer; by id, `agentLabel`/`tools` only choose the token (attribution).
|
|
348
|
+
const target = opts[HELD_RESUME] ?? { agentLabel: opts.agentLabel, tools: opts.tools, adopt: () => undefined };
|
|
349
|
+
const waitS = Math.min(SERVER_WAIT_MAX_S, Math.floor(timeoutMs / 1000));
|
|
350
|
+
trace.phase('request', waitS >= 1 ? 'held' : null);
|
|
351
|
+
const answer = await this.requestResume(workspaceId, {
|
|
352
|
+
waitS,
|
|
353
|
+
agentLabel: target.agentLabel,
|
|
354
|
+
tools: target.tools,
|
|
355
|
+
idempotencyKey: opts.idempotencyKey,
|
|
356
|
+
signal: waitOpts.signal,
|
|
357
|
+
onRetry: trace.onRetry,
|
|
358
|
+
});
|
|
359
|
+
if (answer.operation)
|
|
360
|
+
trace.observe(answer.operation);
|
|
361
|
+
if (answer.ready) {
|
|
362
|
+
// Held (200): the running workspace and its token, no wait, view or token request.
|
|
363
|
+
target.adopt(answer.workspace, answer.toolToken);
|
|
364
|
+
if (!answer.operation)
|
|
365
|
+
throw alreadyRunning(answer);
|
|
366
|
+
return answer.operation;
|
|
367
|
+
}
|
|
368
|
+
const operation = answer.operation;
|
|
369
|
+
if (operation.state === 'failed' || operation.state === 'canceled')
|
|
370
|
+
throw new OperationFailedError(operation);
|
|
371
|
+
let final = operation;
|
|
372
|
+
if (operation.state !== 'succeeded') {
|
|
373
|
+
const left = waitS >= 1 ? { timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)) } : {};
|
|
374
|
+
final = await this.waitForOperation(operation.id, { ...waitOpts, ...left, [TRACE]: trace });
|
|
375
|
+
}
|
|
376
|
+
await opts[AFTER_WAIT]?.(trace, final);
|
|
377
|
+
return final;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* One `POST /v1/workspaces/{id}/resume` (0.9.0), held for up to `waitS` seconds when that is at least 1 (`Prefer:
|
|
382
|
+
* wait`, contracts §22.6), asking for the tool token of `agentLabel`/`tools`. `ready` only for a 200 the server says it
|
|
383
|
+
* held (`Preference-Applied`); every other answer is the operation to wait for, so a server without the held resume
|
|
384
|
+
* works unchanged. Refusals (409 operation_in_progress, already_running without the hold, ...) throw as usual.
|
|
385
|
+
* `Workspace.wake()` and `resume({ wait })` use it; most callers want those.
|
|
386
|
+
*/
|
|
387
|
+
async requestResume(workspaceId, o) {
|
|
388
|
+
const s = Math.min(SERVER_WAIT_MAX_S, Math.floor(o.waitS));
|
|
389
|
+
const held = s >= 1;
|
|
390
|
+
const body = {};
|
|
391
|
+
if (held && o.agentLabel !== undefined)
|
|
392
|
+
body.agent_label = o.agentLabel;
|
|
393
|
+
if (held && o.tools !== undefined)
|
|
394
|
+
body.tools = [...o.tools];
|
|
395
|
+
const init = {
|
|
396
|
+
idempotencyKey: o.idempotencyKey ?? randomId('op-'),
|
|
397
|
+
...(Object.keys(body).length > 0 ? { json: body } : {}),
|
|
398
|
+
...(o.signal ? { signal: o.signal } : {}),
|
|
399
|
+
...(o.onRetry ? { onRetry: o.onRetry } : {}),
|
|
400
|
+
};
|
|
401
|
+
if (held) {
|
|
402
|
+
init.headers = { prefer: `wait=${s}` };
|
|
403
|
+
// The held response may take the whole wait: the request timeout must outlast it.
|
|
404
|
+
init.timeoutMs = Math.max(this.#http.opts.timeoutMs, s * 1000 + 10_000);
|
|
405
|
+
}
|
|
406
|
+
const res = await this.#http.jsonWithHeaders('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/resume`, init, this.#auth);
|
|
407
|
+
const b = res.body;
|
|
408
|
+
if (!b || typeof b !== 'object' || !b.workspace)
|
|
409
|
+
throw new ShardfluxProtocolError('resume: response has no workspace', res.status);
|
|
410
|
+
const pa = res.headers.get('preference-applied');
|
|
411
|
+
const ready = held && res.status === 200 && pa !== null && /\bwait\s*=/i.test(pa);
|
|
412
|
+
if (!ready && !b.operation)
|
|
413
|
+
throw new ShardfluxProtocolError('resume: response has no operation', res.status);
|
|
414
|
+
const toolToken = ready ? (b.tool_token ?? null) : null;
|
|
415
|
+
this.#noteToken(toolToken);
|
|
416
|
+
return { ready, operation: b.operation ?? null, workspace: b.workspace, toolToken, requestId: res.headers.get('x-request-id') };
|
|
321
417
|
}
|
|
322
418
|
snapshot(workspaceId, opts = {}) {
|
|
323
419
|
return this.#op('snapshot', workspaceId, opts.label === undefined ? {} : { label: opts.label }, opts);
|
|
324
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* Suspends the workspace once it has been idle for `afterSeconds` (0.9.0; contracts §20.6): meant for the end of an
|
|
423
|
+
* agent turn, so the workspace stops using RAM soon after instead of waiting out its idle policy. The idle time
|
|
424
|
+
* counts from the later of the workspace's last work and this request. A command still running, an attached exec or
|
|
425
|
+
* terminal stream, or a keepalive postpones the suspend until `afterSeconds` after it ends; a tool call after the
|
|
426
|
+
* request (the next turn) or a resume cancels it. Repeating replaces the pending request. It applies under every idle
|
|
427
|
+
* policy (`never` included) and never delays a suspend the policy would do sooner.
|
|
428
|
+
*
|
|
429
|
+
* Resolves with the recorded `suspendRequest`, or, when a suspend is already in progress, with that `operation` and
|
|
430
|
+
* nothing recorded. Tool-call capture writes recorded before the call land first (a later write would count as the
|
|
431
|
+
* next turn). Errors: ShardfluxApiError 409 with reason `not_running`, `operation_in_progress`, `session_lifetime` or
|
|
432
|
+
* `workspace_deleted`; 422 `validation_failed` for `afterSeconds` outside 30..3600.
|
|
433
|
+
*
|
|
434
|
+
* await cloud.workspaces.suspendWhenIdle(workspace.id, { afterSeconds: 60 });
|
|
435
|
+
*/
|
|
436
|
+
async suspendWhenIdle(workspaceId, opts) {
|
|
437
|
+
await this.#ctx().captures.settle(workspaceId);
|
|
438
|
+
const body = await this.#http.json('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/suspend-when-idle`, { json: { after_seconds: opts.afterSeconds }, idempotencyKey: opts.idempotencyKey ?? randomId('op-') }, this.#auth);
|
|
439
|
+
return { suspendRequest: body.suspend_request, operation: body.operation, workspace: this.#wrap(body.workspace) };
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Cancels a pending suspend-when-idle request (0.10.0; DELETE /v1/workspaces/{id}/suspend-when-idle). Idempotent,
|
|
443
|
+
* in any workspace state; resolves with the workspace (`suspendRequest` null). A suspend the request already started
|
|
444
|
+
* is not undone: it is the workspace's `activeOperation` (resume or open the workspace instead).
|
|
445
|
+
*/
|
|
446
|
+
async cancelSuspendWhenIdle(workspaceId) {
|
|
447
|
+
return this.#wrap(await this.#http.json('DELETE', `/v1/workspaces/${encodeURIComponent(workspaceId)}/suspend-when-idle`, {}, this.#auth));
|
|
448
|
+
}
|
|
325
449
|
async close(workspaceId, opts = {}) {
|
|
326
450
|
return (await this.closeWithView(workspaceId, opts)).operation;
|
|
327
451
|
}
|
|
@@ -439,8 +563,9 @@ export class Shardflux {
|
|
|
439
563
|
this.egress = new EgressPolicyApi(() => this.#ctx);
|
|
440
564
|
this.audit = new AuditApi(() => this.#ctx);
|
|
441
565
|
this.volumes = new VolumesApi(() => this.#ctx);
|
|
566
|
+
const baseUrl = opts.baseUrl ?? 'https://api.shardflux.dev';
|
|
442
567
|
this.#ctx = {
|
|
443
|
-
http: new HttpClient({ baseUrl
|
|
568
|
+
http: new HttpClient({ baseUrl, fetch: f, userAgent, timeoutMs: opts.timeoutMs ?? 30_000, maxRetries: opts.maxRetries ?? 2, source: 'api', sleep, onSuccess: versionCheckHook(opts.versionCheck, baseUrl, f, userAgent) }),
|
|
444
569
|
authorization: `Bearer ${opts.apiKey}`,
|
|
445
570
|
fetch: f,
|
|
446
571
|
userAgent,
|
|
@@ -457,6 +582,17 @@ export class Shardflux {
|
|
|
457
582
|
entitlements(organizationId) {
|
|
458
583
|
return this.#ctx.http.json('GET', `/v1/organizations/${encodeURIComponent(organizationId)}/entitlements`, {}, this.#ctx.authorization);
|
|
459
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Send feedback straight to the Shardflux founder (0.9.0+; POST /v1/feedback), who reads every message. Use it while
|
|
587
|
+
* you work, the moment something fails unexpectedly, an error or doc is confusing, something is missing or slow, or a
|
|
588
|
+
* workaround was needed; short and specific beats polished, and `context.requestId` / `errorCode` let the founder find
|
|
589
|
+
* the logs. `context.client` defaults to `shardflux-sdk-ts/<SDK_VERSION>`. Returns `{ id, receivedAt, duplicate }`
|
|
590
|
+
* (`duplicate`: the same message from this key within 24 hours; no second email). 429 `rate_limited` (with
|
|
591
|
+
* `retryAfterSeconds`) and 422 `validation_failed` are ShardfluxApiErrors; the call is never retried.
|
|
592
|
+
*/
|
|
593
|
+
sendFeedback(params) {
|
|
594
|
+
return sendFeedback(this.#ctx, params);
|
|
595
|
+
}
|
|
460
596
|
/** Raw access to any /v1 endpoint with the SDK's authentication and error handling. */
|
|
461
597
|
request(method, path, init = {}) {
|
|
462
598
|
return this.#ctx.http.json(method, path, init, this.#ctx.authorization);
|
package/dist/errors.d.ts
CHANGED
|
@@ -19,9 +19,40 @@ export type ErrorCode = AppErrorCode | CellErrorCode;
|
|
|
19
19
|
* services_unsupported, input_required, input_unknown, input_invalid, egress_widening, reserved_session_id,
|
|
20
20
|
* env_collision, reserved_template_slug; 409 conflict package_index_unavailable; 404 package_not_found; the operation
|
|
21
21
|
* error `startup_failed` (details.reason startup_failed, service_not_ready, secrets_unavailable, secret_not_available,
|
|
22
|
-
* guest_feature_unavailable).
|
|
22
|
+
* guest_feature_unavailable). File tools and parking (contracts §25-§28, 0.9.0) added: 409 conflict revision_mismatch
|
|
23
|
+
* (details.current_revision); 422 validation_failed edit_not_found, edit_ambiguous (details.index), edit_not_text,
|
|
24
|
+
* patch_invalid; 503 service_unavailable host_capacity and wake_failed (both retryable, with Retry-After); 409
|
|
25
|
+
* workspace_busy workspace_fenced (a lifecycle operation holds the workspace; waited out like any workspace_busy).
|
|
26
|
+
* Reads of a sleeping workspace (§26.4/§26.5): 409 workspace_not_running offline_unavailable and offline_budget
|
|
27
|
+
* (retryable; the disk could not be read offline: the workspace is woken and the call retried like any
|
|
28
|
+
* workspace_not_running), 503 offline_changed (retryable; the retry is served by the guest).
|
|
29
|
+
* Hosts without the features (contracts §26.7, 0.9.0): 409 conflict host_feature_unavailable (not retryable;
|
|
30
|
+
* details.feature `file_search` or `file_patch`): the workspace runs on a host agent that predates the call, until it
|
|
31
|
+
* runs on an upgraded host (minutes to hours); read and write the file, or run a search command, instead.
|
|
32
|
+
* File-first workspaces (contracts §29.7, §29.8; 0.9.0) added: 409 conflict not_supported_for_mode (details.mode,
|
|
33
|
+
* details.operation; NotSupportedForModeError), mode_mismatch (reopening a key with another mode), layout_unsupported (a
|
|
34
|
+
* legacy template), tree_revision_mismatch (details.current_tree_revision; TreeRevisionMismatchError),
|
|
35
|
+
* execution_id_reused, operation_id_reused (an Idempotency-Key reused for another request); 422 validation_failed
|
|
36
|
+
* mode_not_available (the deployment does not offer file-first workspaces), outside_tree_root (also 404 on reads); 409
|
|
37
|
+
* workspace_busy execution_in_progress (details.execution_id; waited out like any workspace_busy); 503
|
|
38
|
+
* service_unavailable no_execution_host (retryable, Retry-After). An execution that ended `failed` or `lost` names its
|
|
39
|
+
* cause in `error.details.reason`: lease_expired, host_unreachable, host_restarted, tree_moved, blob_missing,
|
|
40
|
+
* blob_corrupt, exec_failed_to_start, among others.
|
|
41
|
+
* Working directories (0.10.0): 422 validation_failed invalid_cwd (details.field `cwd`): an exec, execution or PTY start
|
|
42
|
+
* named a relative cwd, which is refused rather than resolved (the message names the absolute path it likely means);
|
|
43
|
+
* a processful command that could not start rejects `exec.run()` with ExecStartError (409 conflict
|
|
44
|
+
* exec_failed_to_start).
|
|
45
|
+
* Opt-in overage with a spend cap (0.10.0): 402 allowance_exhausted on opens, resumes and forks carries details.reason
|
|
46
|
+
* allowance_used (a CPU-hours or RAM GiB-hours allowance is used up and overage is off or not on the plan),
|
|
47
|
+
* overage_paused (overage is on but paused while a plan payment is past due) or spend_cap_reached (overage charges
|
|
48
|
+
* reached the spend cap), plus details.spend_cap {cap_minor, effective_cap_minor, charges_minor, currency}; an API
|
|
49
|
+
* older than overage sends no reason. Changing overage is for owners and billing members (the console, or a user
|
|
50
|
+
* session: ShardfluxAccount.billing.setSpendPolicy; an API key gets 403), whose 422 validation_failed reasons are
|
|
51
|
+
* overage_unavailable, spend_cap_required, spend_cap_below_minimum (details.min_minor), spend_cap_above_plan_price
|
|
52
|
+
* (details.max_minor) and spend_cap_below_charges (details.charges_minor); with `ifMatch`, 409 conflict
|
|
53
|
+
* version_mismatch (details.current_version), as egress puts with `ifMatch` answer too.
|
|
23
54
|
*/
|
|
24
|
-
export type KnownErrorReason = 'invalid_recipe' | 'base_not_layered' | 'language_unavailable' | 'language_conflict' | 'invalid_package' | 'too_many_files' | 'platform_owned_path' | 'upload_required' | 'upload_missing' | 'upload_digest_mismatch' | 'upload_too_large' | 'extra_hosts_without_auto' | 'invalid_settings' | 'services_unsupported' | 'input_required' | 'input_unknown' | 'input_invalid' | 'egress_widening' | 'reserved_session_id' | 'env_collision' | 'reserved_template_slug' | 'package_index_unavailable' | 'package_not_found' | 'startup_failed' | 'service_not_ready' | 'secrets_unavailable' | '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';
|
|
55
|
+
export type KnownErrorReason = 'invalid_recipe' | 'base_not_layered' | 'language_unavailable' | 'language_conflict' | 'invalid_package' | 'too_many_files' | 'platform_owned_path' | 'upload_required' | 'upload_missing' | 'upload_digest_mismatch' | 'upload_too_large' | 'extra_hosts_without_auto' | 'invalid_settings' | 'services_unsupported' | 'input_required' | 'input_unknown' | 'input_invalid' | 'egress_widening' | 'reserved_session_id' | 'env_collision' | 'reserved_template_slug' | 'package_index_unavailable' | 'package_not_found' | 'startup_failed' | 'service_not_ready' | 'secrets_unavailable' | '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' | 'revision_mismatch' | 'edit_not_found' | 'edit_ambiguous' | 'edit_not_text' | 'patch_invalid' | 'host_capacity' | 'wake_failed' | 'workspace_fenced' | 'offline_unavailable' | 'offline_budget' | 'offline_changed' | 'host_feature_unavailable' | 'not_supported_for_mode' | 'mode_mismatch' | 'mode_not_available' | 'layout_unsupported' | 'tree_revision_mismatch' | 'outside_tree_root' | 'execution_in_progress' | 'execution_id_reused' | 'operation_id_reused' | 'no_execution_host' | 'lease_expired' | 'host_unreachable' | 'host_restarted' | 'tree_moved' | 'blob_missing' | 'blob_corrupt' | 'exec_failed_to_start' | 'invalid_cwd' | 'allowance_used' | 'overage_paused' | 'spend_cap_reached' | 'overage_unavailable' | 'spend_cap_required' | 'spend_cap_below_minimum' | 'spend_cap_above_plan_price' | 'spend_cap_below_charges' | 'version_mismatch';
|
|
25
56
|
/** A known reason, or any other string the server sends (reasons are open-ended). */
|
|
26
57
|
export type ErrorReason = KnownErrorReason | (string & {});
|
|
27
58
|
export interface ErrorBodyLike {
|
|
@@ -49,8 +80,64 @@ export declare class ShardfluxApiError extends Error {
|
|
|
49
80
|
readonly reason: ErrorReason | undefined;
|
|
50
81
|
/** Where the time went when a traced call (open, a waited lifecycle call, a wake) failed with this error. */
|
|
51
82
|
timing: LifecycleTiming | undefined;
|
|
52
|
-
|
|
83
|
+
/**
|
|
84
|
+
* `X-Tree-Revision` of the refusal (file-first workspaces, contracts §29.8): the tree revision the refused call saw.
|
|
85
|
+
* Undefined when the response carried none (processful workspaces, the application API).
|
|
86
|
+
*/
|
|
87
|
+
readonly treeRevision: number | undefined;
|
|
88
|
+
constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number);
|
|
53
89
|
}
|
|
90
|
+
/** processful (the default: one VM keeps processes, memory and files) or file_first (contracts §29). */
|
|
91
|
+
export type WorkspaceMode = AppComponents['schemas']['WorkspaceMode'];
|
|
92
|
+
/**
|
|
93
|
+
* The call does not exist for the workspace's mode (contracts §29.7, §29.8): 409 `conflict` with details.reason
|
|
94
|
+
* `not_supported_for_mode`, `details.mode` (the workspace's mode) and `details.operation`. File-first workspaces have no
|
|
95
|
+
* VM between executions, so exec sessions, PTY, processes, version control, browser, changes, suspend, resume,
|
|
96
|
+
* snapshot, fork, reset, save-as-template, volumes and idle policies are refused; processful workspaces have no
|
|
97
|
+
* executions and no tree revisions. When the handle knows the workspace's mode the SDK raises it before any request
|
|
98
|
+
* (`local` true); otherwise it is the server's refusal. Either way `err.reason === 'not_supported_for_mode'`.
|
|
99
|
+
*/
|
|
100
|
+
export declare class NotSupportedForModeError extends ShardfluxApiError {
|
|
101
|
+
/** The workspace's mode (null when the server did not say). */
|
|
102
|
+
readonly mode: WorkspaceMode | null;
|
|
103
|
+
/** The refused call, e.g. `pty.open`, `suspend`, `executions.run` (null when the server did not say). */
|
|
104
|
+
readonly operation: string | null;
|
|
105
|
+
/** True when the SDK refused the call itself, without a request. */
|
|
106
|
+
readonly local: boolean;
|
|
107
|
+
constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number, local?: boolean);
|
|
108
|
+
/** The SDK's own refusal of `operation` for a workspace whose mode it knows (no request was made). */
|
|
109
|
+
static local(mode: WorkspaceMode, operation: string, source: 'api' | 'cell', message: string): NotSupportedForModeError;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A file-first call with `ifTreeRevision` (`If-Match`) found the tree at another revision (contracts §29.8): 409
|
|
113
|
+
* `conflict`, details.reason `tree_revision_mismatch`. Nothing changed. `currentTreeRevision` is the revision the tree
|
|
114
|
+
* is at: read what changed, then retry against it.
|
|
115
|
+
*/
|
|
116
|
+
export declare class TreeRevisionMismatchError extends ShardfluxApiError {
|
|
117
|
+
readonly currentTreeRevision: number | null;
|
|
118
|
+
constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number);
|
|
119
|
+
}
|
|
120
|
+
type ExecSession = CellComponents['schemas']['ExecSession'];
|
|
121
|
+
/**
|
|
122
|
+
* A command that could not start (0.10.0+): its working directory is not a directory, its program is not on PATH, or
|
|
123
|
+
* its user does not exist. Nothing ran, so there is no exit code and no output. `exec.run()` rejects with it when the
|
|
124
|
+
* session ends `failed_to_start` (before 0.10.0 it resolved with `exitCode: null` and empty output). It is a 409
|
|
125
|
+
* `conflict` with details.reason `exec_failed_to_start`, as a file-first execution that could not start reports it;
|
|
126
|
+
* the message carries the workspace's own words, e.g. `working directory "/home/user/app" is not a directory`.
|
|
127
|
+
*/
|
|
128
|
+
export declare class ExecStartError extends ShardfluxApiError {
|
|
129
|
+
/** The exec session that could not start. */
|
|
130
|
+
readonly sessionId: string;
|
|
131
|
+
/** The session as the cell reported it (state `failed_to_start`, `error`). */
|
|
132
|
+
readonly session: ExecSession;
|
|
133
|
+
constructor(session: ExecSession);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Builds the error for an error body, typed by its reason: NotSupportedForModeError for `not_supported_for_mode`,
|
|
137
|
+
* TreeRevisionMismatchError for `tree_revision_mismatch`, else ShardfluxApiError. Every SDK request builds its errors
|
|
138
|
+
* here, so `instanceof` works whichever call was refused.
|
|
139
|
+
*/
|
|
140
|
+
export declare function apiError(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number): ShardfluxApiError;
|
|
54
141
|
/** The response was not the documented shape (e.g. a proxy error page). */
|
|
55
142
|
export declare class ShardfluxProtocolError extends Error {
|
|
56
143
|
readonly status: number;
|
package/dist/errors.js
CHANGED
|
@@ -19,7 +19,12 @@ export class ShardfluxApiError extends Error {
|
|
|
19
19
|
reason;
|
|
20
20
|
/** Where the time went when a traced call (open, a waited lifecycle call, a wake) failed with this error. */
|
|
21
21
|
timing = undefined;
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* `X-Tree-Revision` of the refusal (file-first workspaces, contracts §29.8): the tree revision the refused call saw.
|
|
24
|
+
* Undefined when the response carried none (processful workspaces, the application API).
|
|
25
|
+
*/
|
|
26
|
+
treeRevision;
|
|
27
|
+
constructor(status, body, source, retryAfterSeconds, treeRevision) {
|
|
23
28
|
super(body.error.message);
|
|
24
29
|
this.name = 'ShardfluxApiError';
|
|
25
30
|
this.status = status;
|
|
@@ -30,10 +35,97 @@ export class ShardfluxApiError extends Error {
|
|
|
30
35
|
this.operationId = body.error.operation_id;
|
|
31
36
|
this.source = source;
|
|
32
37
|
this.retryAfterSeconds = retryAfterSeconds;
|
|
38
|
+
this.treeRevision = treeRevision;
|
|
33
39
|
const reason = body.error.details?.reason;
|
|
34
40
|
this.reason = typeof reason === 'string' ? reason : undefined;
|
|
35
41
|
}
|
|
36
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The call does not exist for the workspace's mode (contracts §29.7, §29.8): 409 `conflict` with details.reason
|
|
45
|
+
* `not_supported_for_mode`, `details.mode` (the workspace's mode) and `details.operation`. File-first workspaces have no
|
|
46
|
+
* VM between executions, so exec sessions, PTY, processes, version control, browser, changes, suspend, resume,
|
|
47
|
+
* snapshot, fork, reset, save-as-template, volumes and idle policies are refused; processful workspaces have no
|
|
48
|
+
* executions and no tree revisions. When the handle knows the workspace's mode the SDK raises it before any request
|
|
49
|
+
* (`local` true); otherwise it is the server's refusal. Either way `err.reason === 'not_supported_for_mode'`.
|
|
50
|
+
*/
|
|
51
|
+
export class NotSupportedForModeError extends ShardfluxApiError {
|
|
52
|
+
/** The workspace's mode (null when the server did not say). */
|
|
53
|
+
mode;
|
|
54
|
+
/** The refused call, e.g. `pty.open`, `suspend`, `executions.run` (null when the server did not say). */
|
|
55
|
+
operation;
|
|
56
|
+
/** True when the SDK refused the call itself, without a request. */
|
|
57
|
+
local;
|
|
58
|
+
constructor(status, body, source, retryAfterSeconds, treeRevision, local = false) {
|
|
59
|
+
super(status, body, source, retryAfterSeconds, treeRevision);
|
|
60
|
+
this.name = 'NotSupportedForModeError';
|
|
61
|
+
const mode = body.error.details?.mode;
|
|
62
|
+
const operation = body.error.details?.operation;
|
|
63
|
+
this.mode = mode === 'processful' || mode === 'file_first' ? mode : null;
|
|
64
|
+
this.operation = typeof operation === 'string' && operation !== '' ? operation : null;
|
|
65
|
+
this.local = local;
|
|
66
|
+
}
|
|
67
|
+
/** The SDK's own refusal of `operation` for a workspace whose mode it knows (no request was made). */
|
|
68
|
+
static local(mode, operation, source, message) {
|
|
69
|
+
return new NotSupportedForModeError(409, { error: { code: 'conflict', message, request_id: '', retryable: false, details: { reason: 'not_supported_for_mode', mode, operation } } }, source, undefined, undefined, true);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A file-first call with `ifTreeRevision` (`If-Match`) found the tree at another revision (contracts §29.8): 409
|
|
74
|
+
* `conflict`, details.reason `tree_revision_mismatch`. Nothing changed. `currentTreeRevision` is the revision the tree
|
|
75
|
+
* is at: read what changed, then retry against it.
|
|
76
|
+
*/
|
|
77
|
+
export class TreeRevisionMismatchError extends ShardfluxApiError {
|
|
78
|
+
currentTreeRevision;
|
|
79
|
+
constructor(status, body, source, retryAfterSeconds, treeRevision) {
|
|
80
|
+
super(status, body, source, retryAfterSeconds, treeRevision);
|
|
81
|
+
this.name = 'TreeRevisionMismatchError';
|
|
82
|
+
const cur = body.error.details?.current_tree_revision;
|
|
83
|
+
this.currentTreeRevision = typeof cur === 'number' && Number.isSafeInteger(cur) ? cur : (treeRevision ?? null);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A command that could not start (0.10.0+): its working directory is not a directory, its program is not on PATH, or
|
|
88
|
+
* its user does not exist. Nothing ran, so there is no exit code and no output. `exec.run()` rejects with it when the
|
|
89
|
+
* session ends `failed_to_start` (before 0.10.0 it resolved with `exitCode: null` and empty output). It is a 409
|
|
90
|
+
* `conflict` with details.reason `exec_failed_to_start`, as a file-first execution that could not start reports it;
|
|
91
|
+
* the message carries the workspace's own words, e.g. `working directory "/home/user/app" is not a directory`.
|
|
92
|
+
*/
|
|
93
|
+
export class ExecStartError extends ShardfluxApiError {
|
|
94
|
+
/** The exec session that could not start. */
|
|
95
|
+
sessionId;
|
|
96
|
+
/** The session as the cell reported it (state `failed_to_start`, `error`). */
|
|
97
|
+
session;
|
|
98
|
+
constructor(session) {
|
|
99
|
+
const why = session.error ?? 'the workspace gave no reason';
|
|
100
|
+
super(409, {
|
|
101
|
+
error: {
|
|
102
|
+
code: 'conflict',
|
|
103
|
+
message: `The command could not start: ${why}`,
|
|
104
|
+
request_id: '',
|
|
105
|
+
retryable: false,
|
|
106
|
+
details: { reason: 'exec_failed_to_start', session_id: session.session_id, ...(session.error ? { error: session.error } : {}) },
|
|
107
|
+
},
|
|
108
|
+
}, 'cell');
|
|
109
|
+
this.name = 'ExecStartError';
|
|
110
|
+
this.sessionId = session.session_id;
|
|
111
|
+
this.session = session;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Builds the error for an error body, typed by its reason: NotSupportedForModeError for `not_supported_for_mode`,
|
|
116
|
+
* TreeRevisionMismatchError for `tree_revision_mismatch`, else ShardfluxApiError. Every SDK request builds its errors
|
|
117
|
+
* here, so `instanceof` works whichever call was refused.
|
|
118
|
+
*/
|
|
119
|
+
export function apiError(status, body, source, retryAfterSeconds, treeRevision) {
|
|
120
|
+
switch (body.error.details?.reason) {
|
|
121
|
+
case 'not_supported_for_mode':
|
|
122
|
+
return new NotSupportedForModeError(status, body, source, retryAfterSeconds, treeRevision);
|
|
123
|
+
case 'tree_revision_mismatch':
|
|
124
|
+
return new TreeRevisionMismatchError(status, body, source, retryAfterSeconds, treeRevision);
|
|
125
|
+
default:
|
|
126
|
+
return new ShardfluxApiError(status, body, source, retryAfterSeconds, treeRevision);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
37
129
|
/** The response was not the documented shape (e.g. a proxy error page). */
|
|
38
130
|
export class ShardfluxProtocolError extends Error {
|
|
39
131
|
status;
|