@shardflux/sdk 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +189 -1
- package/README.md +118 -11
- package/dist/cell.d.ts +51 -4
- package/dist/cell.js +138 -22
- package/dist/client.d.ts +30 -6
- package/dist/client.js +33 -5
- package/dist/errors.d.ts +40 -2
- package/dist/errors.js +10 -0
- package/dist/generated/app-api.d.ts +351 -55
- package/dist/generated/cell-api.d.ts +172 -10
- package/dist/http.d.ts +45 -3
- package/dist/http.js +85 -12
- package/dist/index.d.ts +6 -6
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +7 -1
- package/dist/lifecycle.js +2 -2
- package/dist/progress.d.ts +47 -7
- package/dist/progress.js +35 -1
- package/dist/templates.d.ts +25 -3
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +275 -7
- package/dist/usage.d.ts +3 -1
- package/dist/usage.js +3 -1
- package/dist/workspace.d.ts +36 -10
- package/dist/workspace.js +27 -3
- package/package.json +1 -1
package/dist/progress.d.ts
CHANGED
|
@@ -74,9 +74,10 @@ export interface ServerTiming {
|
|
|
74
74
|
warmFallback: string | null;
|
|
75
75
|
/**
|
|
76
76
|
* `result.resume_path`: `local_cache`, `prestaged` (copied to this host ahead of the resume),
|
|
77
|
-
* `download` (the checkpoint had to be fetched first), `cold_boot` (0.11.0+: the saved disk was booted
|
|
78
|
-
*
|
|
79
|
-
* reset).
|
|
77
|
+
* `download` (the checkpoint had to be fetched first), `cold_boot` (0.11.0+: the saved disk was booted instead of
|
|
78
|
+
* restoring memory, see `coldBootReason`; processes restarted; see `memoryRestored`), `reset_blank_layer` (the first
|
|
79
|
+
* start after a reset) or `thaw` (0.13.1+: resumed while its suspend was still being written; the same VM continued
|
|
80
|
+
* in place).
|
|
80
81
|
*/
|
|
81
82
|
resumePath: string | null;
|
|
82
83
|
/**
|
|
@@ -87,10 +88,11 @@ export interface ServerTiming {
|
|
|
87
88
|
*/
|
|
88
89
|
memoryRestored?: boolean | null;
|
|
89
90
|
/**
|
|
90
|
-
* `result.cold_boot_reason` (0.11.0+), with `resumePath` `cold_boot`: why the memory could not be restored
|
|
91
|
-
* `runtime_changed` (the platform's VM runtime changed after the suspend).
|
|
91
|
+
* `result.cold_boot_reason` (0.11.0+), with `resumePath` `cold_boot`: why the memory could not be restored:
|
|
92
|
+
* `runtime_changed` (the platform's VM runtime changed after the suspend) or `host_lost` (0.13.1+: the machine the
|
|
93
|
+
* workspace ran on failed; it booted from its disk, files kept, see `hostLost`). Null otherwise.
|
|
92
94
|
*/
|
|
93
|
-
coldBootReason?:
|
|
95
|
+
coldBootReason?: ColdBootReason | null;
|
|
94
96
|
/**
|
|
95
97
|
* `result.durable` (0.12.0+), suspend and fork: `true` when the capture is in durable storage, `false` while its
|
|
96
98
|
* durable copy is being written (see `durability`). `null` when the result does not say (other kinds, an older API).
|
|
@@ -105,6 +107,12 @@ export interface ServerTiming {
|
|
|
105
107
|
* checkpoint before it (`restoredCheckpointId`, state as of `stateAsOf`). Null otherwise.
|
|
106
108
|
*/
|
|
107
109
|
lostSuspend?: LostSuspend | null;
|
|
110
|
+
/**
|
|
111
|
+
* `result.host_lost` (0.13.1+): the machine the workspace ran on failed. A resume (or open) that restored the
|
|
112
|
+
* workspace says from what (`restoredFrom` `disk`, or `checkpoint` with `stateAsOf`); a suspend that found it so
|
|
113
|
+
* succeeds with only `detectedAt`. Null otherwise.
|
|
114
|
+
*/
|
|
115
|
+
hostLost?: HostLost | null;
|
|
108
116
|
/** `result.boot_to_ready_ms`: VM start until the guest agent answered. */
|
|
109
117
|
bootToReadyMs: number | null;
|
|
110
118
|
/** `result.host_timings_ms`: the host's own steps (restore: load, after_restore, ready, …). */
|
|
@@ -143,6 +151,30 @@ export interface LostSuspend {
|
|
|
143
151
|
/** The restored state is as of this time (RFC 3339). */
|
|
144
152
|
stateAsOf: string | null;
|
|
145
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* `result.cold_boot_reason` (0.11.0+): `runtime_changed` (the platform's VM runtime changed after the suspend) or
|
|
156
|
+
* `host_lost` (0.13.1+: the machine the workspace ran on failed). Any other string is a reason this version does not
|
|
157
|
+
* know.
|
|
158
|
+
*/
|
|
159
|
+
export type ColdBootReason = 'runtime_changed' | 'host_lost' | (string & {});
|
|
160
|
+
/**
|
|
161
|
+
* `result.host_lost` (0.13.1+), camelCased: the machine the workspace ran on failed. The workspace was moved to
|
|
162
|
+
* `suspended` at that moment, and its next use (a resume, a tool call's wake, an open) restored it. See the lifecycle
|
|
163
|
+
* reference.
|
|
164
|
+
*/
|
|
165
|
+
export interface HostLost {
|
|
166
|
+
/** When the failure was detected (RFC 3339). */
|
|
167
|
+
detectedAt: string | null;
|
|
168
|
+
/**
|
|
169
|
+
* What the resume restored: `disk` (the workspace's own disk, files kept; `coldBootReason` `host_lost`, processes
|
|
170
|
+
* restarted) or `checkpoint` (its newest checkpoint, see `stateAsOf`). Null on a suspend's result.
|
|
171
|
+
*/
|
|
172
|
+
restoredFrom: 'disk' | 'checkpoint' | null;
|
|
173
|
+
/** `checkpoint`: the checkpoint the resume restored. */
|
|
174
|
+
restoredCheckpointId: string | null;
|
|
175
|
+
/** `checkpoint`: the restored state is as of this time (RFC 3339); changes after it are not in the workspace. */
|
|
176
|
+
stateAsOf: string | null;
|
|
177
|
+
}
|
|
146
178
|
/**
|
|
147
179
|
* The durable copy of a suspend or fork operation (0.12.0+): `result.durability` camelCased, or null when the result has
|
|
148
180
|
* none (the suspend was stored durably before it completed, or another kind).
|
|
@@ -150,6 +182,11 @@ export interface LostSuspend {
|
|
|
150
182
|
export declare function durabilityOf(op: Pick<Operation, 'result'>): Durability | null;
|
|
151
183
|
/** A resume operation's `result.lost_suspend` camelCased (0.12.0+), or null. */
|
|
152
184
|
export declare function lostSuspendOf(op: Pick<Operation, 'result'>): LostSuspend | null;
|
|
185
|
+
/**
|
|
186
|
+
* An operation's `result.host_lost` camelCased (0.13.1+), or null: on a resume or open that restored a workspace whose
|
|
187
|
+
* machine failed, and on a suspend that found it so (`detectedAt` only).
|
|
188
|
+
*/
|
|
189
|
+
export declare function hostLostOf(op: Pick<Operation, 'result'>): HostLost | null;
|
|
153
190
|
/**
|
|
154
191
|
* Whether a suspend or fork operation's capture is in durable storage (0.12.0+): `result.durable`, else `true` for a
|
|
155
192
|
* succeeded suspend or fork whose result predates the field, else null.
|
|
@@ -255,7 +292,10 @@ export declare function traced<T>(trace: Trace, fn: () => Promise<T>): Promise<T
|
|
|
255
292
|
*
|
|
256
293
|
* A call that retried a request adds `retries: <n> (<request>, <cause>, after <delay>)`.
|
|
257
294
|
* A resume that booted the workspace instead of restoring its memory (0.11.0+) says so in the server line:
|
|
258
|
-
* `resume from cold_boot: processes restarted (runtime_changed)
|
|
295
|
+
* `resume from cold_boot: processes restarted (runtime_changed)`, or `(host_lost)` (0.13.1+) when the machine the
|
|
296
|
+
* workspace ran on failed and it booted from its disk. A resume that restored the newest checkpoint after such a
|
|
297
|
+
* failure adds `restored checkpoint <id> (host_lost; state as of <time>)`; a suspend that found the machine failed
|
|
298
|
+
* adds `host_lost (detected <time>)`.
|
|
259
299
|
*/
|
|
260
300
|
export declare function formatTiming(t: LifecycleTiming): string;
|
|
261
301
|
export {};
|
package/dist/progress.js
CHANGED
|
@@ -40,6 +40,22 @@ export function lostSuspendOf(op) {
|
|
|
40
40
|
stateAsOf: str(r.state_as_of),
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* An operation's `result.host_lost` camelCased (0.13.1+), or null: on a resume or open that restored a workspace whose
|
|
45
|
+
* machine failed, and on a suspend that found it so (`detectedAt` only).
|
|
46
|
+
*/
|
|
47
|
+
export function hostLostOf(op) {
|
|
48
|
+
const h = op.result?.host_lost;
|
|
49
|
+
if (typeof h !== 'object' || h === null || Array.isArray(h))
|
|
50
|
+
return null;
|
|
51
|
+
const r = h;
|
|
52
|
+
return {
|
|
53
|
+
detectedAt: str(r.detected_at),
|
|
54
|
+
restoredFrom: r.restored_from === 'disk' || r.restored_from === 'checkpoint' ? r.restored_from : null,
|
|
55
|
+
restoredCheckpointId: str(r.restored_checkpoint_id),
|
|
56
|
+
stateAsOf: str(r.state_as_of),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
43
59
|
/**
|
|
44
60
|
* Whether a suspend or fork operation's capture is in durable storage (0.12.0+): `result.durable`, else `true` for a
|
|
45
61
|
* succeeded suspend or fork whose result predates the field, else null.
|
|
@@ -122,6 +138,7 @@ export function serverTiming(op) {
|
|
|
122
138
|
suspendPath: str(r.suspend_path),
|
|
123
139
|
durability: durabilityOf(op),
|
|
124
140
|
lostSuspend: lostSuspendOf(op),
|
|
141
|
+
hostLost: hostLostOf(op),
|
|
125
142
|
bootToReadyMs: num(r.boot_to_ready_ms),
|
|
126
143
|
hostTimingsMs,
|
|
127
144
|
};
|
|
@@ -275,6 +292,19 @@ export async function traced(trace, fn) {
|
|
|
275
292
|
}
|
|
276
293
|
}
|
|
277
294
|
const fmt = (ms) => (ms === null ? '?' : ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`);
|
|
295
|
+
/**
|
|
296
|
+
* The server-line part of `result.host_lost` (0.13.1+): the checkpoint a resume restored, or a suspend's detection time.
|
|
297
|
+
* A resume from the disk says nothing more here: its cold boot reads `processes restarted (host_lost)`.
|
|
298
|
+
*/
|
|
299
|
+
function hostLostText(h) {
|
|
300
|
+
if (!h)
|
|
301
|
+
return null;
|
|
302
|
+
if (h.restoredFrom === 'checkpoint')
|
|
303
|
+
return `restored checkpoint ${h.restoredCheckpointId ?? '?'} (host_lost; state as of ${h.stateAsOf ?? '?'})`;
|
|
304
|
+
if (h.restoredFrom === null)
|
|
305
|
+
return `host_lost${h.detectedAt ? ` (detected ${h.detectedAt})` : ''}`;
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
278
308
|
/**
|
|
279
309
|
* A human-readable account of a timing, for logs and bug reports. A resume in production:
|
|
280
310
|
*
|
|
@@ -285,7 +315,10 @@ const fmt = (ms) => (ms === null ? '?' : ms < 1000 ? `${Math.round(ms)} ms` : `$
|
|
|
285
315
|
*
|
|
286
316
|
* A call that retried a request adds `retries: <n> (<request>, <cause>, after <delay>)`.
|
|
287
317
|
* A resume that booted the workspace instead of restoring its memory (0.11.0+) says so in the server line:
|
|
288
|
-
* `resume from cold_boot: processes restarted (runtime_changed)
|
|
318
|
+
* `resume from cold_boot: processes restarted (runtime_changed)`, or `(host_lost)` (0.13.1+) when the machine the
|
|
319
|
+
* workspace ran on failed and it booted from its disk. A resume that restored the newest checkpoint after such a
|
|
320
|
+
* failure adds `restored checkpoint <id> (host_lost; state as of <time>)`; a suspend that found the machine failed
|
|
321
|
+
* adds `host_lost (detected <time>)`.
|
|
289
322
|
*/
|
|
290
323
|
export function formatTiming(t) {
|
|
291
324
|
const ids = [t.workspaceId ? `workspace ${t.workspaceId}` : null, t.operationId ? `operation ${t.operationId}` : null].filter(Boolean).join(', ');
|
|
@@ -308,6 +341,7 @@ export function formatTiming(t) {
|
|
|
308
341
|
s.startPath ? `start ${s.startPath}${s.warmFallback ? ` (warm fallback: ${s.warmFallback})` : ''}` : null,
|
|
309
342
|
s.resumePath ? `resume from ${s.resumePath}${s.memoryRestored === false ? `: processes restarted${s.coldBootReason ? ` (${s.coldBootReason})` : ''}` : ''}` : null,
|
|
310
343
|
s.lostSuspend ? `restored ${s.lostSuspend.restoredCheckpointId ?? 'no checkpoint'} (latest suspend ${s.lostSuspend.checkpointId} ${s.lostSuspend.reason ?? 'lost'})` : null,
|
|
344
|
+
hostLostText(s.hostLost),
|
|
311
345
|
s.suspendPath === 'local_commit' ? 'sealed on host' : null,
|
|
312
346
|
s.durability?.state === 'durable' ? `durable${s.durability.localCommitToDurableMs !== null ? ` ${fmt(s.durability.localCommitToDurableMs)} later` : ''}` : s.durability ? `durable copy ${s.durability.state}` : null,
|
|
313
347
|
s.bootToReadyMs !== null ? `boot to ready ${fmt(s.bootToReadyMs)}` : null,
|
package/dist/templates.d.ts
CHANGED
|
@@ -176,6 +176,17 @@ export interface TemplateVersion {
|
|
|
176
176
|
introduced_in_version: number | null;
|
|
177
177
|
}>;
|
|
178
178
|
storage: TemplateStorage;
|
|
179
|
+
/**
|
|
180
|
+
* Immutable paths (0.13.0): the directories this version declares read-only and the size of its image, or null when
|
|
181
|
+
* it declares none. Workspaces of the template mount these paths from the template's newest published version (the
|
|
182
|
+
* open version) and pick up a newer one when they cold-boot or resume.
|
|
183
|
+
*/
|
|
184
|
+
immutable: TemplateVersionImmutable | null;
|
|
185
|
+
}
|
|
186
|
+
/** A version's immutable paths (0.13.0): the read-only directories (byte order) and the bytes of their image. */
|
|
187
|
+
export interface TemplateVersionImmutable {
|
|
188
|
+
paths: string[];
|
|
189
|
+
bytes: number;
|
|
179
190
|
}
|
|
180
191
|
/** Summary of an organization template's live draft on the template view (null when none). */
|
|
181
192
|
export interface TemplateDraftSummary {
|
|
@@ -207,8 +218,6 @@ export interface TemplateSummary {
|
|
|
207
218
|
open_version: TemplateVersion | null;
|
|
208
219
|
/** The live draft (organization templates in dev mode), or null. */
|
|
209
220
|
draft: TemplateDraftSummary | null;
|
|
210
|
-
/** Reserved (T2): always `pinned` in T1. */
|
|
211
|
-
update_policy: 'pinned' | 'auto';
|
|
212
221
|
/** Platform templates: `os` or `stack` (0.7.0); organization templates: null. */
|
|
213
222
|
category: TemplateCategory | null;
|
|
214
223
|
}
|
|
@@ -333,9 +342,14 @@ export interface TemplateBuild {
|
|
|
333
342
|
archived_at: string | null;
|
|
334
343
|
} | null;
|
|
335
344
|
};
|
|
345
|
+
/**
|
|
346
|
+
* Why the build failed. `details` carries code-specific fields (0.13.0): `immutable_path_missing` (an immutable path is
|
|
347
|
+
* not a directory in the built filesystem) names it in `details.path`; `immutable_image_too_large` has none.
|
|
348
|
+
*/
|
|
336
349
|
failure: {
|
|
337
350
|
code: string;
|
|
338
351
|
message: string;
|
|
352
|
+
details?: Record<string, unknown>;
|
|
339
353
|
} | null;
|
|
340
354
|
log: {
|
|
341
355
|
state: 'none' | 'available' | 'expired';
|
|
@@ -361,6 +375,12 @@ export interface TemplateBuild {
|
|
|
361
375
|
squashed: boolean | null;
|
|
362
376
|
/** Organization bytes of the produced chain (limit: the plan's template_org_bytes_max). */
|
|
363
377
|
org_bytes: number | null;
|
|
378
|
+
/**
|
|
379
|
+
* The immutable paths the produced version declares (0.13.0), in byte order: the recipe's `immutable`, else the list
|
|
380
|
+
* of the template's open version (a save from a workspace: the target template's open version's, else the
|
|
381
|
+
* workspace's version's). Empty for none.
|
|
382
|
+
*/
|
|
383
|
+
immutable_paths: string[];
|
|
364
384
|
}
|
|
365
385
|
export interface TemplateRecipe {
|
|
366
386
|
/** `<template slug>@<version>`, referenced in the Dockerfile as `FROM shardflux-base`. */
|
|
@@ -386,7 +406,9 @@ export interface CreateTemplateBuildParams {
|
|
|
386
406
|
/**
|
|
387
407
|
* Recipe v1 (a Dockerfile) or recipe v2 (0.7.0; `schema: "shardflux.template-recipe.v2"`: languages, packages,
|
|
388
408
|
* uploaded files, steps, auto network and settings). Recipe v2 files reference uploads
|
|
389
|
-
* (`templates.uploads.put()`); `buildFromFile()` / `buildFromRecipe()` upload local `from` paths for you.
|
|
409
|
+
* (`templates.uploads.put()`); `buildFromFile()` / `buildFromRecipe()` upload local `from` paths for you. A recipe v2
|
|
410
|
+
* may declare `immutable` (0.13.0): up to 8 directories every workspace of the template mounts read-only from its
|
|
411
|
+
* newest published version (omitted: the open version's list; a new version keeps every path of it).
|
|
390
412
|
*/
|
|
391
413
|
recipe: TemplateRecipe | TemplateRecipeV2;
|
|
392
414
|
/** Publish the produced version as soon as it is registered (default true); false leaves it for the owner/admin publish route. */
|
package/dist/tools.d.ts
CHANGED
|
@@ -85,6 +85,13 @@ export interface WorkspaceToolsOptions {
|
|
|
85
85
|
* with `workspace.executions.get(id)`.
|
|
86
86
|
*/
|
|
87
87
|
onExecution?: (executionId: string) => void;
|
|
88
|
+
/**
|
|
89
|
+
* Offer burst execution on the processful `exec` tool (0.13.0; off by default: it needs the
|
|
90
|
+
* organization's burst execution entitlement). The tool then takes `burst`
|
|
91
|
+
* (`'always'` runs the command on a larger, short-lived burst VM and applies its file changes back), `burst_vcpus`
|
|
92
|
+
* and `burst_memory_mib`, and its result adds `burst` (the summary) for a burst. The file-first `exec` is unchanged.
|
|
93
|
+
*/
|
|
94
|
+
burst?: boolean;
|
|
88
95
|
}
|
|
89
96
|
/** Builds the tool list for a workspace. Synchronous: tokens are fetched on first use. */
|
|
90
97
|
export declare function workspaceTools(workspace: Workspace, opts?: WorkspaceToolsOptions): WorkspaceTool[];
|
package/dist/tools.js
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* application; toOpenAITools/toAnthropicTools export the definitions in those
|
|
11
11
|
* providers' formats and executeToolCall dispatches a model's tool call.
|
|
12
12
|
*/
|
|
13
|
-
import { CAPTURE_BARRIER } from "./cell.js";
|
|
14
|
-
import { ExecStartError } from "./errors.js";
|
|
13
|
+
import { CAPTURE_BARRIER, burstFailure } from "./cell.js";
|
|
14
|
+
import { ExecStartError, ShardfluxApiError } from "./errors.js";
|
|
15
15
|
import { newExecutionId } from "./executions.js";
|
|
16
16
|
export class ToolArgumentError extends Error {
|
|
17
17
|
tool;
|
|
@@ -98,6 +98,144 @@ function clip(text, max) {
|
|
|
98
98
|
return { text, truncated: false };
|
|
99
99
|
return { text: bytes.subarray(0, max).toString('utf8'), truncated: true };
|
|
100
100
|
}
|
|
101
|
+
const toolError = (e) => ({ code: e.code, message: e.message, reason: e.reason });
|
|
102
|
+
/**
|
|
103
|
+
* Why a session ended without running to an exit code, as the exec tools report it: a burst's recorded failure, a
|
|
104
|
+
* command that could not start (as ExecStartError says it), or the session's own reason (e.g. lost). Null otherwise.
|
|
105
|
+
*/
|
|
106
|
+
function sessionError(s) {
|
|
107
|
+
if (s.burst?.error)
|
|
108
|
+
return toolError(burstFailure(s.burst.error));
|
|
109
|
+
if (s.state === 'failed_to_start')
|
|
110
|
+
return toolError(new ExecStartError(s));
|
|
111
|
+
if (s.error)
|
|
112
|
+
return { code: 'internal_error', message: `The command ended without an exit status: ${s.error}`, reason: undefined };
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const running = (s) => s.state === 'starting' || s.state === 'running';
|
|
116
|
+
/** A UTF-8 continuation byte (10xxxxxx). */
|
|
117
|
+
const continuation = (b) => (b & 0xc0) === 0x80;
|
|
118
|
+
/** The length of the UTF-8 sequence a lead byte starts (0 for a byte that cannot start one). */
|
|
119
|
+
const sequenceLength = (b) => (b < 0x80 ? 1 : (b & 0xe0) === 0xc0 ? 2 : (b & 0xf0) === 0xe0 ? 3 : (b & 0xf8) === 0xf0 ? 4 : 0);
|
|
120
|
+
/** `end`, moved back to the start of the UTF-8 sequence it would cut (unchanged when it cuts none). */
|
|
121
|
+
function utf8End(bytes, end) {
|
|
122
|
+
for (let i = end - 1; i >= Math.max(0, end - 3); i--) {
|
|
123
|
+
const b = bytes[i];
|
|
124
|
+
if (continuation(b))
|
|
125
|
+
continue;
|
|
126
|
+
const n = sequenceLength(b);
|
|
127
|
+
return n > 0 && i + n > end ? i : end;
|
|
128
|
+
}
|
|
129
|
+
return end;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* One stream of an exec_read: the bytes from `start`, kept up to the tools' limit plus one byte (which says that more
|
|
133
|
+
* follows). Chunks are byte ranges of the stream (`offset` is the stream offset of their first byte), split anywhere,
|
|
134
|
+
* also inside a character: the text is decoded once, from the bytes joined.
|
|
135
|
+
*/
|
|
136
|
+
class OutputWindow {
|
|
137
|
+
start;
|
|
138
|
+
#max;
|
|
139
|
+
#chunks = [];
|
|
140
|
+
#kept = 0;
|
|
141
|
+
/** How far the stream is known to reach: the session's size, or the end of a chunk seen after it. */
|
|
142
|
+
#end;
|
|
143
|
+
constructor(start, max, size) {
|
|
144
|
+
this.start = start;
|
|
145
|
+
this.#max = max;
|
|
146
|
+
this.#end = size;
|
|
147
|
+
}
|
|
148
|
+
get full() {
|
|
149
|
+
return this.#kept > this.#max;
|
|
150
|
+
}
|
|
151
|
+
push(offset, data) {
|
|
152
|
+
this.#end = Math.max(this.#end, offset + data.length);
|
|
153
|
+
const at = this.start + this.#kept;
|
|
154
|
+
if (this.full || offset > at || offset + data.length <= at)
|
|
155
|
+
return;
|
|
156
|
+
const part = data.subarray(at - offset, at - offset + this.#max + 1 - this.#kept);
|
|
157
|
+
this.#chunks.push(part);
|
|
158
|
+
this.#kept += part.length;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The text from the first character boundary at or after `start` (`offset`) to `next`: at most the limit, never a
|
|
162
|
+
* cut character (the cut moves back to its first byte, and a running command's last, incomplete character waits for
|
|
163
|
+
* the next read). `truncated`: the stream holds more than the limit past `start`.
|
|
164
|
+
*/
|
|
165
|
+
result(ended) {
|
|
166
|
+
const bytes = Buffer.concat(this.#chunks);
|
|
167
|
+
let skip = 0;
|
|
168
|
+
if (this.start > 0)
|
|
169
|
+
while (skip < 3 && skip < bytes.length && continuation(bytes[skip]))
|
|
170
|
+
skip += 1;
|
|
171
|
+
const truncated = this.#end - this.start > this.#max;
|
|
172
|
+
let end = Math.min(bytes.length, this.#max);
|
|
173
|
+
if (truncated || !ended) {
|
|
174
|
+
const cut = utf8End(bytes, end);
|
|
175
|
+
// A limit too small for one character still makes progress.
|
|
176
|
+
end = cut > skip || !truncated ? Math.max(cut, skip) : Math.max(end, skip);
|
|
177
|
+
}
|
|
178
|
+
return { text: new TextDecoder().decode(bytes.subarray(skip, end)), offset: this.start + skip, next: this.start + end, truncated };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** A failure event of an exec output stream: a burst's recorded outcome (on its session too), or a stream failure. */
|
|
182
|
+
const BURST_FAILURES = new Set(['burst_unavailable', 'burst_apply_failed']);
|
|
183
|
+
/**
|
|
184
|
+
* Reads the output that exists (follow=false) into the windows given, stopping once they are full. Returns the
|
|
185
|
+
* session of an `exit` event (the session ended meanwhile), else null.
|
|
186
|
+
*/
|
|
187
|
+
async function drainOutput(c, id, from, into, signal) {
|
|
188
|
+
const windows = [into.stdout, into.stderr].filter((w) => w !== undefined);
|
|
189
|
+
for await (const ev of await c.exec.output(id, { stdoutOffset: from.stdout, stderrOffset: from.stderr, follow: false, ...(signal ? { signal } : {}) })) {
|
|
190
|
+
if (ev.type === 'output' && ev.data !== undefined) {
|
|
191
|
+
const w = ev.stream === 'stderr' ? into.stderr : into.stdout;
|
|
192
|
+
w?.push(ev.offset ?? 0, Buffer.from(ev.data, 'base64'));
|
|
193
|
+
if (windows.every((x) => x.full))
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
else if (ev.type === 'exit' && ev.session) {
|
|
197
|
+
return ev.session;
|
|
198
|
+
}
|
|
199
|
+
else if (ev.type === 'error' && ev.error && !BURST_FAILURES.has(ev.error.error.code)) {
|
|
200
|
+
throw new ShardfluxApiError(502, ev.error, 'cell');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Waits up to `waitMs` for a running session to end and returns it as it is then. Follows the output from the current
|
|
207
|
+
* sizes, so only what the command writes meanwhile flows, and answers on the `exit` event; a dropped stream is
|
|
208
|
+
* followed again while time is left.
|
|
209
|
+
*/
|
|
210
|
+
async function waitForExit(c, s, waitMs, signal) {
|
|
211
|
+
const deadline = Date.now() + waitMs;
|
|
212
|
+
let current = s;
|
|
213
|
+
for (let attempt = 0; attempt < 5 && running(current) && Date.now() < deadline; attempt++) {
|
|
214
|
+
const timer = new AbortController();
|
|
215
|
+
const t = setTimeout(() => timer.abort(), Math.max(0, deadline - Date.now()));
|
|
216
|
+
const sig = signal ? AbortSignal.any([signal, timer.signal]) : timer.signal;
|
|
217
|
+
try {
|
|
218
|
+
for await (const ev of await c.exec.output(current.session_id, { stdoutOffset: current.stdout_size, stderrOffset: current.stderr_size, follow: true, signal: sig })) {
|
|
219
|
+
if (ev.type === 'exit')
|
|
220
|
+
return ev.session ?? (await c.exec.get(current.session_id));
|
|
221
|
+
if (ev.type === 'error')
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
if (signal?.aborted)
|
|
227
|
+
throw e;
|
|
228
|
+
// A refusal (e.g. an unknown session) is the answer; the end of the wait or a dropped stream is not.
|
|
229
|
+
if (!timer.signal.aborted && e instanceof ShardfluxApiError && !e.retryable)
|
|
230
|
+
throw e;
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
clearTimeout(t);
|
|
234
|
+
}
|
|
235
|
+
current = await c.exec.get(current.session_id);
|
|
236
|
+
}
|
|
237
|
+
return current;
|
|
238
|
+
}
|
|
101
239
|
/** Builds the tool list for a workspace. Synchronous: tokens are fetched on first use. */
|
|
102
240
|
export function workspaceTools(workspace, opts = {}) {
|
|
103
241
|
const cell = () => workspace.cell({
|
|
@@ -117,6 +255,30 @@ export function workspaceTools(workspace, opts = {}) {
|
|
|
117
255
|
timeout_ms: { type: 'integer', minimum: 1000, maximum: 3_600_000, description: 'Kill the command after this long (default 600000).' },
|
|
118
256
|
stdin: { type: 'string', maxLength: 1_000_000, description: 'Text written to stdin.' },
|
|
119
257
|
}, ['command']);
|
|
258
|
+
// The processful exec: background sessions (0.13.0; read with exec_read, stopped with exec_cancel), a timeout up to a
|
|
259
|
+
// day, and with burst execution (opt-in) its extra inputs.
|
|
260
|
+
const sessionId = { type: 'string', minLength: 1, maxLength: 64 };
|
|
261
|
+
const processfulExecParameters = obj({
|
|
262
|
+
command: execParameters.properties.command,
|
|
263
|
+
cwd: execParameters.properties.cwd,
|
|
264
|
+
timeout_ms: { type: 'integer', minimum: 1000, maximum: 86_400_000, description: 'Kill the command after this long (default 600000; with background, none unless set).' },
|
|
265
|
+
stdin: execParameters.properties.stdin,
|
|
266
|
+
background: {
|
|
267
|
+
type: 'boolean',
|
|
268
|
+
description: 'Start the command and return its session_id at once instead of waiting for it to exit (default false). Use it for anything that may run longer than a few minutes, such as a build, a test suite, a training run or a server. Keep using the other tools meanwhile, read it with exec_read and stop it with exec_cancel. Set timeout_ms to the longest it may run: the workspace stays awake until the command ends or that time passes (1 hour when unset).',
|
|
269
|
+
},
|
|
270
|
+
...(opts.burst
|
|
271
|
+
? {
|
|
272
|
+
burst: {
|
|
273
|
+
type: 'string',
|
|
274
|
+
enum: ['never', 'always'],
|
|
275
|
+
description: 'always: run this heavy, self-terminating command (a cold build, a large test suite) on a larger, short-lived burst VM over a copy of the workspace, and apply its file changes back when it exits. Processes it starts do not survive; not with stdin. Default never.',
|
|
276
|
+
},
|
|
277
|
+
burst_vcpus: { type: 'integer', minimum: 1, maximum: 32, description: 'With burst always: the burst VM’s vCPUs (default 8, or the plan’s ceiling when lower).' },
|
|
278
|
+
burst_memory_mib: { type: 'integer', minimum: 512, maximum: 65536, description: 'With burst always: the burst VM’s memory in MiB (default 8192, or the plan’s ceiling when lower).' },
|
|
279
|
+
}
|
|
280
|
+
: {}),
|
|
281
|
+
}, ['command']);
|
|
120
282
|
const defs = [
|
|
121
283
|
fileFirst
|
|
122
284
|
? {
|
|
@@ -158,15 +320,42 @@ export function workspaceTools(workspace, opts = {}) {
|
|
|
158
320
|
: {
|
|
159
321
|
name: 'exec',
|
|
160
322
|
permission: 'exec',
|
|
161
|
-
description: 'Run a shell command in the persistent remote workspace (Linux; bash -lc) and return its exit code, stdout and stderr. Files, installed packages and background processes persist between calls.',
|
|
162
|
-
parameters:
|
|
323
|
+
description: 'Run a shell command in the persistent remote workspace (Linux; bash -lc) and return its exit code, stdout and stderr. Files, installed packages and background processes persist between calls. A command that may run longer than a few minutes can run in the background (background: true).',
|
|
324
|
+
parameters: processfulExecParameters,
|
|
325
|
+
// A burst fences the workspace until it applies its changes: the other tools could not run meanwhile.
|
|
326
|
+
refuse: (a) => a.background === true && opts.burst && a.burst === 'always'
|
|
327
|
+
? ['background cannot be combined with burst always: a burst holds the workspace until it applies its changes; run it without background']
|
|
328
|
+
: [],
|
|
163
329
|
run: async (a, o) => {
|
|
330
|
+
const burst = opts.burst && (a.burst === 'always' || a.burst === 'never') ? a.burst : undefined;
|
|
331
|
+
const burstVcpus = opts.burst && typeof a.burst_vcpus === 'number' ? a.burst_vcpus : undefined;
|
|
332
|
+
const burstMemoryMib = opts.burst && typeof a.burst_memory_mib === 'number' ? a.burst_memory_mib : undefined;
|
|
333
|
+
const cwd = typeof a.cwd === 'string' ? a.cwd : opts.defaultCwd;
|
|
334
|
+
if (a.background === true) {
|
|
335
|
+
// A session of its own: the start answers at once, and exec_read / exec_cancel find it by session_id.
|
|
336
|
+
// The command runs until it exits or its timeout_ms, if one is set. (Never a burst: refused above.)
|
|
337
|
+
const s = await cell().exec.start({
|
|
338
|
+
argv: ['bash', '-lc', String(a.command)],
|
|
339
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
340
|
+
...(typeof a.timeout_ms === 'number' ? { timeout_ms: a.timeout_ms } : {}),
|
|
341
|
+
...(typeof a.stdin === 'string' ? { stdin: Buffer.from(a.stdin, 'utf8').toString('base64') } : {}),
|
|
342
|
+
...(burst !== undefined ? { burst } : {}),
|
|
343
|
+
...(burstVcpus !== undefined ? { burst_vcpus: burstVcpus } : {}),
|
|
344
|
+
...(burstMemoryMib !== undefined ? { burst_memory_mib: burstMemoryMib } : {}),
|
|
345
|
+
}, o.signal);
|
|
346
|
+
// Nothing ran (e.g. a cwd that is not a directory): the reason, as the foreground exec gives it.
|
|
347
|
+
const error = s.state === 'failed_to_start' ? sessionError(s) : null;
|
|
348
|
+
return { session_id: s.session_id, state: s.state, ...(error ? { error } : {}) };
|
|
349
|
+
}
|
|
164
350
|
let r;
|
|
165
351
|
try {
|
|
166
352
|
r = await cell().exec.run(['bash', '-lc', String(a.command)], {
|
|
167
|
-
...(
|
|
353
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
168
354
|
timeoutMs: typeof a.timeout_ms === 'number' ? a.timeout_ms : 600_000,
|
|
169
355
|
...(typeof a.stdin === 'string' ? { stdin: a.stdin } : {}),
|
|
356
|
+
...(burst !== undefined ? { burst } : {}),
|
|
357
|
+
...(burstVcpus !== undefined ? { burstVcpus } : {}),
|
|
358
|
+
...(burstMemoryMib !== undefined ? { burstMemoryMib } : {}),
|
|
170
359
|
maxOutputBytes: max,
|
|
171
360
|
...(o.signal ? { signal: o.signal } : {}),
|
|
172
361
|
});
|
|
@@ -183,12 +372,89 @@ export function workspaceTools(workspace, opts = {}) {
|
|
|
183
372
|
stderr: '',
|
|
184
373
|
truncated: false,
|
|
185
374
|
session_id: e.sessionId,
|
|
186
|
-
error:
|
|
375
|
+
error: toolError(e),
|
|
187
376
|
};
|
|
188
377
|
}
|
|
189
|
-
return {
|
|
378
|
+
return {
|
|
379
|
+
exit_code: r.exitCode,
|
|
380
|
+
term_signal: r.termSignal,
|
|
381
|
+
timed_out: r.timedOut,
|
|
382
|
+
stdout: r.stdout,
|
|
383
|
+
stderr: r.stderr,
|
|
384
|
+
truncated: r.truncated,
|
|
385
|
+
session_id: r.sessionId,
|
|
386
|
+
// Elastic workspaces (0.13.0): the memory grow the command waited for, when one ran.
|
|
387
|
+
...(r.memoryGrow ? { memory_grow: r.memoryGrow } : {}),
|
|
388
|
+
// Burst execution (0.13.0, opt-in): what the burst VM ran and applied.
|
|
389
|
+
...(r.burst ? { burst: r.burst } : {}),
|
|
390
|
+
};
|
|
190
391
|
},
|
|
191
392
|
},
|
|
393
|
+
// Background sessions (0.13.0): exec_read and exec_cancel need exec sessions, which a file-first workspace has none of.
|
|
394
|
+
...(fileFirst
|
|
395
|
+
? []
|
|
396
|
+
: [
|
|
397
|
+
{
|
|
398
|
+
name: 'exec_read',
|
|
399
|
+
permission: 'exec',
|
|
400
|
+
description: 'Read a command started with exec background: true: its state, exit code and output. Without offsets it returns the end of each stream; pass next_stdout_offset and next_stderr_offset back to read only new output. wait_ms waits up to that long for the command to exit and returns as soon as it does.',
|
|
401
|
+
parameters: obj({ session_id: sessionId, stdout_offset: { type: 'integer', minimum: 0 }, stderr_offset: { type: 'integer', minimum: 0 }, wait_ms: { type: 'integer', minimum: 0, maximum: 60_000 } }, ['session_id']),
|
|
402
|
+
run: async (a, o) => {
|
|
403
|
+
const c = cell();
|
|
404
|
+
const id = String(a.session_id);
|
|
405
|
+
let s = await c.exec.get(id);
|
|
406
|
+
if (typeof a.wait_ms === 'number' && a.wait_ms > 0 && running(s))
|
|
407
|
+
s = await waitForExit(c, s, a.wait_ms, o.signal);
|
|
408
|
+
// Each stream from the offset given, else its last `max` bytes (as they are after the wait).
|
|
409
|
+
const out = new OutputWindow(typeof a.stdout_offset === 'number' ? a.stdout_offset : Math.max(0, s.stdout_size - max), max, s.stdout_size);
|
|
410
|
+
const err = new OutputWindow(typeof a.stderr_offset === 'number' ? a.stderr_offset : Math.max(0, s.stderr_size - max), max, s.stderr_size);
|
|
411
|
+
if (out.start < s.stdout_size || err.start < s.stderr_size) {
|
|
412
|
+
// The guest sends one stream's output up to its end before the other's: when stdout holds more than
|
|
413
|
+
// a window past its offset, stderr is read by a request of its own.
|
|
414
|
+
const ended = err.start < s.stderr_size && s.stdout_size - out.start > max
|
|
415
|
+
? (await Promise.all([
|
|
416
|
+
drainOutput(c, id, { stdout: out.start, stderr: s.stderr_size }, { stdout: out }, o.signal),
|
|
417
|
+
drainOutput(c, id, { stdout: s.stdout_size, stderr: err.start }, { stderr: err }, o.signal),
|
|
418
|
+
])).find((x) => x !== null)
|
|
419
|
+
: await drainOutput(c, id, { stdout: out.start, stderr: err.start }, { stdout: out, stderr: err }, o.signal);
|
|
420
|
+
if (ended)
|
|
421
|
+
s = ended;
|
|
422
|
+
}
|
|
423
|
+
const done = !running(s);
|
|
424
|
+
const so = out.result(done);
|
|
425
|
+
const se = err.result(done);
|
|
426
|
+
const error = sessionError(s);
|
|
427
|
+
return {
|
|
428
|
+
session_id: s.session_id,
|
|
429
|
+
state: s.state,
|
|
430
|
+
exit_code: done ? (s.exit_code ?? null) : null,
|
|
431
|
+
term_signal: s.term_signal ?? null,
|
|
432
|
+
timed_out: s.timed_out ?? false,
|
|
433
|
+
canceled: s.canceled ?? false,
|
|
434
|
+
stdout: so.text,
|
|
435
|
+
stderr: se.text,
|
|
436
|
+
stdout_offset: so.offset,
|
|
437
|
+
stderr_offset: se.offset,
|
|
438
|
+
next_stdout_offset: so.next,
|
|
439
|
+
next_stderr_offset: se.next,
|
|
440
|
+
truncated: so.truncated || se.truncated,
|
|
441
|
+
...(error ? { error } : {}),
|
|
442
|
+
...(s.burst ? { burst: s.burst } : {}),
|
|
443
|
+
};
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: 'exec_cancel',
|
|
448
|
+
permission: 'exec',
|
|
449
|
+
description: 'Stop a command started with exec background: true: SIGTERM, then SIGKILL after grace_ms (default 5000).',
|
|
450
|
+
// The workspace waits 5 s without a grace and at most 60 s.
|
|
451
|
+
parameters: obj({ session_id: sessionId, grace_ms: { type: 'integer', minimum: 1, maximum: 60_000 } }, ['session_id']),
|
|
452
|
+
run: async (a) => {
|
|
453
|
+
const s = await cell().exec.cancel(String(a.session_id), typeof a.grace_ms === 'number' ? a.grace_ms : undefined);
|
|
454
|
+
return { session_id: s.session_id, state: s.state, exit_code: running(s) ? null : (s.exit_code ?? null), canceled: s.canceled ?? false };
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
]),
|
|
192
458
|
{
|
|
193
459
|
name: 'read_file',
|
|
194
460
|
permission: 'files',
|
|
@@ -434,6 +700,8 @@ export function workspaceTools(workspace, opts = {}) {
|
|
|
434
700
|
permission: d.permission,
|
|
435
701
|
execute: async (args, options = {}) => {
|
|
436
702
|
const issues = validateArgs(d.parameters, args);
|
|
703
|
+
if (issues.length === 0 && d.refuse)
|
|
704
|
+
issues.push(...d.refuse(args));
|
|
437
705
|
if (issues.length > 0)
|
|
438
706
|
throw new ToolArgumentError(`${prefix}${d.name}`, issues);
|
|
439
707
|
// Fire and forget: a parked workspace starts restoring while this call is prepared. Not for
|
package/dist/usage.d.ts
CHANGED
|
@@ -56,7 +56,9 @@ export declare class UsageApi {
|
|
|
56
56
|
constructor(ctx: () => ClientContext);
|
|
57
57
|
/**
|
|
58
58
|
* Current-period usage per meter, allowances with enforcement and cap state (`overage` while opt-in overage covers
|
|
59
|
-
* usage past a CPU-hours or RAM GiB-hours allowance
|
|
59
|
+
* usage past a CPU-hours or RAM GiB-hours allowance; `storage_blocked` (0.14.0+, enforcement `storage_block`) while
|
|
60
|
+
* the Retained state allowance is used up: opening a new key and forking are refused with 403 `quota_exceeded`,
|
|
61
|
+
* details.limit `retained_state`), measurement freshness, `exhausted_reason` (the 402
|
|
60
62
|
* allowance_exhausted reason while starts are refused: `allowance_used`, `overage_paused`, `spend_cap_reached`) and
|
|
61
63
|
* `spend_cap` (0.10.0).
|
|
62
64
|
*/
|
package/dist/usage.js
CHANGED
|
@@ -9,7 +9,9 @@ export class UsageApi {
|
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
11
|
* Current-period usage per meter, allowances with enforcement and cap state (`overage` while opt-in overage covers
|
|
12
|
-
* usage past a CPU-hours or RAM GiB-hours allowance
|
|
12
|
+
* usage past a CPU-hours or RAM GiB-hours allowance; `storage_blocked` (0.14.0+, enforcement `storage_block`) while
|
|
13
|
+
* the Retained state allowance is used up: opening a new key and forking are refused with 403 `quota_exceeded`,
|
|
14
|
+
* details.limit `retained_state`), measurement freshness, `exhausted_reason` (the 402
|
|
13
15
|
* allowance_exhausted reason while starts are refused: `allowance_used`, `overage_paused`, `spend_cap_reached`) and
|
|
14
16
|
* `spend_cap` (0.10.0).
|
|
15
17
|
*/
|