@shardflux/sdk 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cell.d.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Cell gateway client. Every path
3
+ * template is checked at compile time against the generated `paths` type, and
4
+ * request/response bodies use the generated schemas, so drift in the cell
5
+ * contract fails `tsc`. Authorization is a workspace tool token managed by a
6
+ * ToolTokenManager: a `409 stale_epoch` (workspace moved/re-owned) or `401`
7
+ * (token expired or revoked early) invalidates the token and retries once with
8
+ * a fresh one; the retried request is safe because the gateway rejected the
9
+ * first before any effect (and exec/PTY starts are idempotent by session_id).
10
+ *
11
+ * Exec output is retained in the guest and addressed by byte offsets, so
12
+ * `exec.run()` survives gateway restarts/disconnects by reconnecting with the
13
+ * offsets it already processed; it never starts the command a second time.
14
+ */
15
+ import type { components, paths } from './generated/cell-api.js';
16
+ import type { RequestOptions } from './http.js';
17
+ import type { ToolTokenManager } from './tokens.js';
18
+ type S = components['schemas'];
19
+ export type ExecStartRequest = S['ExecStartRequest'];
20
+ export type ExecSession = S['ExecSession'];
21
+ export type OutputEvent = S['OutputEvent'];
22
+ export type PtyOpenRequest = S['PtyOpenRequest'];
23
+ export type PtySession = S['PtySession'];
24
+ export type PtyServerMessage = S['PtyServerMessage'];
25
+ export type ProcessList = S['ProcessList'];
26
+ export type FileInfo = S['FileInfo'];
27
+ export type FileList = S['FileList'];
28
+ export type FileWriteResult = S['FileWriteResult'];
29
+ export type GitCloneRequest = S['GitCloneRequest'];
30
+ export type GitCommitRequest = S['GitCommitRequest'];
31
+ export type GitResult = S['GitResult'];
32
+ export type GitStatus = S['GitStatus'];
33
+ export type BrowserScreenshotRequest = S['BrowserScreenshotRequest'];
34
+ export type BrowserContentRequest = S['BrowserContentRequest'];
35
+ export type BrowserContent = S['BrowserContent'];
36
+ export type Signal = S['SignalValue'];
37
+ type CellPath = keyof paths;
38
+ /** Fills a path template that must exist in cell-api.yaml. */
39
+ export declare function cellPath<P extends CellPath>(template: P, params: Record<string, string | number>): string;
40
+ export interface CellClientOptions {
41
+ fetch?: typeof fetch;
42
+ userAgent?: string;
43
+ timeoutMs?: number;
44
+ /** Retries for idempotent calls on transient failures (default 2). */
45
+ maxRetries?: number;
46
+ sleep?: (ms: number) => Promise<void>;
47
+ }
48
+ export interface RunResult {
49
+ sessionId: string;
50
+ exitCode: number | null;
51
+ termSignal: number | null;
52
+ timedOut: boolean;
53
+ canceled: boolean;
54
+ stdout: string;
55
+ stderr: string;
56
+ stdoutBytes: number;
57
+ stderrBytes: number;
58
+ truncated: boolean;
59
+ session: ExecSession;
60
+ /** Output-stream reconnections performed (gateway restarts, network drops). */
61
+ reconnects: number;
62
+ }
63
+ export interface RunOptions {
64
+ sessionId?: string;
65
+ cwd?: string;
66
+ env?: Record<string, string>;
67
+ user?: string;
68
+ stdin?: string | Uint8Array;
69
+ timeoutMs?: number;
70
+ killGraceMs?: number;
71
+ /** Bytes of stdout and of stderr kept in memory (the rest is counted, not kept); default 1 MiB. */
72
+ maxOutputBytes?: number;
73
+ onOutput?: (stream: 'stdout' | 'stderr', chunk: Uint8Array) => void;
74
+ signal?: AbortSignal;
75
+ /**
76
+ * When `signal` aborts, also cancel the command (SIGTERM, SIGKILL after the grace; default true). Pass false when the
77
+ * command must outlive the caller (e.g. a client that may re-attach to the session later).
78
+ */
79
+ cancelOnAbort?: boolean;
80
+ /** Output reconnect attempts after a dropped stream (default 10). */
81
+ maxReconnects?: number;
82
+ /**
83
+ * Names of customer secrets (contracts §17) the cell injects as environment variables `NAME=value` of this
84
+ * process only (sent as `secret_refs`). Values are resolved at session start with this workspace's tool token and
85
+ * never returned. A name the caller may not use refuses the whole start with 403 `forbidden`
86
+ * (details.reason `secret_not_available`, details.names) and nothing runs; a name also present in `env` is 422.
87
+ */
88
+ secretRefs?: string[];
89
+ }
90
+ /** Parses an NDJSON byte stream into objects (tolerates CRLF and a final unterminated line). */
91
+ export declare function ndjson<T>(body: ReadableStream<Uint8Array>): AsyncGenerator<T>;
92
+ export declare class CellClient {
93
+ #private;
94
+ readonly workspaceId: string;
95
+ readonly tokens: ToolTokenManager;
96
+ constructor(workspaceId: string, tokens: ToolTokenManager, opts?: CellClientOptions);
97
+ /** One authorized request; refreshes the token once on stale_epoch / 401. */
98
+ request(method: string, path: string, init?: RequestOptions): Promise<Response>;
99
+ readonly exec: {
100
+ /** Starts argv (no shell). Idempotent by session_id: an existing session is returned, never re-run. */
101
+ start: (req: ExecStartRequest, signal?: AbortSignal) => Promise<ExecSession>;
102
+ get: (sessionId: string) => Promise<ExecSession>;
103
+ /** Output events from byte offsets (NDJSON). `follow` keeps the stream open until `exit`. */
104
+ output: (sessionId: string, opts?: {
105
+ stdoutOffset?: number;
106
+ stderrOffset?: number;
107
+ follow?: boolean;
108
+ signal?: AbortSignal;
109
+ }) => Promise<AsyncGenerator<OutputEvent>>;
110
+ signal: (sessionId: string, signal: Signal, onlyLeader?: boolean) => Promise<ExecSession>;
111
+ cancel: (sessionId: string, graceMs?: number) => Promise<ExecSession>;
112
+ /**
113
+ * Starts (or re-attaches to) a session and collects its output until it exits, reconnecting
114
+ * with offsets after dropped streams. Never issues a second start for the same session_id.
115
+ */
116
+ run: (argv: string[], opts?: RunOptions) => Promise<RunResult>;
117
+ };
118
+ readonly pty: {
119
+ open: (req?: PtyOpenRequest) => Promise<PtySession>;
120
+ get: (sessionId: string) => Promise<PtySession>;
121
+ close: (sessionId: string) => Promise<PtySession>;
122
+ resize: (sessionId: string, rows: number, cols: number) => Promise<PtySession>;
123
+ input: (sessionId: string, data: string | Uint8Array) => Promise<PtySession>;
124
+ /** wss:// URL of the attach WebSocket (send the tool token as `Authorization: Bearer`). */
125
+ attachUrl: (sessionId: string, offset?: number) => Promise<{
126
+ url: string;
127
+ token: string;
128
+ }>;
129
+ /**
130
+ * Reads PTY output from `offset` over the attach WebSocket until `quietMs` without output or
131
+ * `timeoutMs` in total (request/response helper for agents). Returns the next offset to resume.
132
+ */
133
+ read: (sessionId: string, opts?: {
134
+ offset?: number;
135
+ quietMs?: number;
136
+ timeoutMs?: number;
137
+ maxBytes?: number;
138
+ }) => Promise<{
139
+ output: string;
140
+ nextOffset: number;
141
+ session: PtySession | null;
142
+ exited: boolean;
143
+ }>;
144
+ };
145
+ readonly processes: {
146
+ list: () => Promise<ProcessList>;
147
+ signal: (pid: number, signal: Signal) => Promise<void>;
148
+ };
149
+ readonly files: {
150
+ /**
151
+ * Reads a file. Without `length` the whole file is returned: the gateway caps one read
152
+ * (FILE_MAX_READ_BYTES) and reports the size at read start in `X-File-Size`, so shorter reads
153
+ * are continued from the next offset until that size (a file that shrinks ends the loop early).
154
+ */
155
+ read: (path: string, opts?: {
156
+ offset?: number;
157
+ length?: number;
158
+ }) => Promise<Uint8Array>;
159
+ readText: (path: string, opts?: {
160
+ offset?: number;
161
+ length?: number;
162
+ }) => Promise<string>;
163
+ /** Atomic replace (or append), acknowledged after fsync of file and parent directory. */
164
+ write: (path: string, data: string | Uint8Array, opts?: {
165
+ mode?: string;
166
+ createParents?: boolean;
167
+ append?: boolean;
168
+ idempotencyKey?: string;
169
+ }) => Promise<FileWriteResult>;
170
+ remove: (path: string, opts?: {
171
+ recursive?: boolean;
172
+ }) => Promise<void>;
173
+ stat: (path: string) => Promise<FileInfo>;
174
+ list: (path: string, opts?: {
175
+ limit?: number;
176
+ }) => Promise<FileList>;
177
+ mkdir: (path: string, opts?: {
178
+ parents?: boolean;
179
+ mode?: string;
180
+ }) => Promise<FileInfo>;
181
+ move: (from: string, to: string, opts?: {
182
+ overwrite?: boolean;
183
+ }) => Promise<FileInfo>;
184
+ };
185
+ readonly git: {
186
+ clone: (req: GitCloneRequest) => Promise<GitResult>;
187
+ status: (path: string) => Promise<GitStatus>;
188
+ commit: (req: GitCommitRequest) => Promise<GitResult>;
189
+ };
190
+ readonly browser: {
191
+ screenshot: (req: BrowserScreenshotRequest) => Promise<Uint8Array>;
192
+ content: (req: BrowserContentRequest) => Promise<BrowserContent>;
193
+ };
194
+ }
195
+ export {};
package/dist/cell.js ADDED
@@ -0,0 +1,408 @@
1
+ import { ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
2
+ import { HttpClient, defaultSleep, randomId } from "./http.js";
3
+ /** Fills a path template that must exist in cell-api.yaml. */
4
+ export function cellPath(template, params) {
5
+ return template.replace(/\{([a-z_]+)\}/g, (_, name) => {
6
+ const v = params[name];
7
+ if (v === undefined)
8
+ throw new Error(`missing path parameter ${name}`);
9
+ return encodeURIComponent(String(v));
10
+ });
11
+ }
12
+ const b64 = (bytes) => Buffer.from(typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : bytes).toString('base64');
13
+ const unb64 = (s) => (s ? new Uint8Array(Buffer.from(s, 'base64')) : new Uint8Array());
14
+ /** Parses an NDJSON byte stream into objects (tolerates CRLF and a final unterminated line). */
15
+ export async function* ndjson(body) {
16
+ const decoder = new TextDecoder();
17
+ const reader = body.getReader();
18
+ let buffer = '';
19
+ let finished = false;
20
+ try {
21
+ for (;;) {
22
+ const { done, value } = await reader.read();
23
+ if (done)
24
+ break;
25
+ buffer += decoder.decode(value, { stream: true });
26
+ let nl;
27
+ while ((nl = buffer.indexOf('\n')) >= 0) {
28
+ const line = buffer.slice(0, nl).replace(/\r$/, '');
29
+ buffer = buffer.slice(nl + 1);
30
+ if (line.trim().length > 0)
31
+ yield JSON.parse(line);
32
+ }
33
+ }
34
+ buffer += decoder.decode();
35
+ if (buffer.trim().length > 0)
36
+ yield JSON.parse(buffer);
37
+ finished = true;
38
+ }
39
+ finally {
40
+ // A consumer that stops early (e.g. after `exit`) closes the HTTP stream instead of leaking it.
41
+ if (!finished)
42
+ await reader.cancel().catch(() => undefined);
43
+ reader.releaseLock();
44
+ }
45
+ }
46
+ class ByteSink {
47
+ #max;
48
+ #chunks = [];
49
+ kept = 0;
50
+ total = 0;
51
+ constructor(max) {
52
+ this.#max = max;
53
+ }
54
+ push(b) {
55
+ this.total += b.length;
56
+ const room = this.#max - this.kept;
57
+ if (room <= 0)
58
+ return;
59
+ const part = b.length <= room ? b : b.subarray(0, room);
60
+ this.#chunks.push(part);
61
+ this.kept += part.length;
62
+ }
63
+ text() {
64
+ return new TextDecoder().decode(Buffer.concat(this.#chunks));
65
+ }
66
+ }
67
+ export class CellClient {
68
+ workspaceId;
69
+ tokens;
70
+ #opts;
71
+ #clients = new Map();
72
+ constructor(workspaceId, tokens, opts = {}) {
73
+ this.workspaceId = workspaceId;
74
+ this.tokens = tokens;
75
+ this.#opts = {
76
+ fetch: opts.fetch ?? fetch,
77
+ userAgent: opts.userAgent ?? 'shardflux-sdk-ts',
78
+ timeoutMs: opts.timeoutMs ?? 60_000,
79
+ maxRetries: opts.maxRetries ?? 2,
80
+ sleep: opts.sleep ?? defaultSleep,
81
+ };
82
+ }
83
+ #http(endpoint) {
84
+ let c = this.#clients.get(endpoint);
85
+ if (!c) {
86
+ c = new HttpClient({ baseUrl: endpoint, fetch: this.#opts.fetch, userAgent: this.#opts.userAgent, timeoutMs: this.#opts.timeoutMs, maxRetries: this.#opts.maxRetries, source: 'cell', sleep: this.#opts.sleep });
87
+ this.#clients.set(endpoint, c);
88
+ }
89
+ return c;
90
+ }
91
+ /** One authorized request; refreshes the token once on stale_epoch / 401. */
92
+ async request(method, path, init = {}) {
93
+ for (let attempt = 0;; attempt += 1) {
94
+ const token = await this.tokens.get();
95
+ try {
96
+ return await this.#http(token.cell_endpoint).raw(method, path, init, `Bearer ${token.token}`);
97
+ }
98
+ catch (err) {
99
+ const refreshable = err instanceof ShardfluxApiError && (err.code === 'stale_epoch' || err.status === 401);
100
+ if (!refreshable || attempt > 0)
101
+ throw err;
102
+ this.tokens.invalidate();
103
+ }
104
+ }
105
+ }
106
+ async #json(method, path, init = {}) {
107
+ const res = await this.request(method, path, init);
108
+ if (res.status === 204)
109
+ return undefined;
110
+ const text = await res.text();
111
+ try {
112
+ return (text.length === 0 ? undefined : JSON.parse(text));
113
+ }
114
+ catch {
115
+ throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status);
116
+ }
117
+ }
118
+ async #bytes(method, path, init = {}) {
119
+ const res = await this.request(method, path, init);
120
+ return new Uint8Array(await res.arrayBuffer());
121
+ }
122
+ #p(template, extra = {}) {
123
+ return cellPath(template, { workspace_id: this.workspaceId, ...extra });
124
+ }
125
+ // ---- exec --------------------------------------------------------------------------
126
+ exec = {
127
+ /** Starts argv (no shell). Idempotent by session_id: an existing session is returned, never re-run. */
128
+ start: (req, signal) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec'), { json: { session_id: randomId('x').replace(/-/g, '').slice(0, 32), ...req }, ...(signal ? { signal } : {}) }),
129
+ get: (sessionId) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}', { session_id: sessionId })),
130
+ /** Output events from byte offsets (NDJSON). `follow` keeps the stream open until `exit`. */
131
+ output: async (sessionId, opts = {}) => {
132
+ const res = await this.request('GET', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/output', { session_id: sessionId }), {
133
+ query: { stdout_offset: opts.stdoutOffset ?? 0, stderr_offset: opts.stderrOffset ?? 0, follow: opts.follow ?? true },
134
+ accept: 'application/x-ndjson',
135
+ timeoutMs: opts.follow === false ? undefined : 0,
136
+ ...(opts.signal ? { signal: opts.signal } : {}),
137
+ });
138
+ if (!res.body)
139
+ throw new ShardfluxProtocolError('exec output: empty body', res.status);
140
+ return ndjson(res.body);
141
+ },
142
+ signal: (sessionId, signal, onlyLeader = false) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/signal', { session_id: sessionId }), { json: { signal, only_leader: onlyLeader } }),
143
+ cancel: (sessionId, graceMs) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/cancel', { session_id: sessionId }), { json: graceMs === undefined ? {} : { grace_ms: graceMs } }),
144
+ /**
145
+ * Starts (or re-attaches to) a session and collects its output until it exits, reconnecting
146
+ * with offsets after dropped streams. Never issues a second start for the same session_id.
147
+ */
148
+ run: async (argv, opts = {}) => {
149
+ const sessionId = opts.sessionId ?? `run-${randomId().replace(/-/g, '').slice(0, 24)}`;
150
+ const req = { session_id: sessionId, argv };
151
+ if (opts.cwd !== undefined)
152
+ req.cwd = opts.cwd;
153
+ if (opts.env !== undefined)
154
+ req.env = opts.env;
155
+ if (opts.user !== undefined)
156
+ req.user = opts.user;
157
+ if (opts.stdin !== undefined)
158
+ req.stdin = b64(opts.stdin);
159
+ if (opts.timeoutMs !== undefined)
160
+ req.timeout_ms = opts.timeoutMs;
161
+ if (opts.killGraceMs !== undefined)
162
+ req.kill_grace_ms = opts.killGraceMs;
163
+ if (opts.secretRefs !== undefined)
164
+ req.secret_refs = opts.secretRefs;
165
+ await this.exec.start(req, opts.signal);
166
+ try {
167
+ return await this.#collect(sessionId, opts);
168
+ }
169
+ catch (e) {
170
+ // Aborting the caller must stop the command too, not only our HTTP calls (MCP cancellation, timeouts):
171
+ // best-effort cancel (SIGTERM, SIGKILL after the grace), bounded so the abort stays prompt.
172
+ if (opts.signal?.aborted && opts.cancelOnAbort !== false) {
173
+ let timer;
174
+ const bound = new Promise((resolve) => {
175
+ timer = setTimeout(resolve, 5_000);
176
+ });
177
+ await Promise.race([this.exec.cancel(sessionId, opts.killGraceMs).then(() => undefined, () => undefined), bound]);
178
+ clearTimeout(timer);
179
+ }
180
+ throw e;
181
+ }
182
+ },
183
+ };
184
+ /** exec.run's output loop: offsets, reconnects, exit. */
185
+ async #collect(sessionId, opts) {
186
+ let session;
187
+ const max = opts.maxOutputBytes ?? 1_048_576;
188
+ const out = new ByteSink(max);
189
+ const err = new ByteSink(max);
190
+ let so = 0;
191
+ let se = 0;
192
+ let reconnects = 0;
193
+ const maxReconnects = opts.maxReconnects ?? 10;
194
+ for (;;) {
195
+ let exited;
196
+ try {
197
+ const events = await this.exec.output(sessionId, { stdoutOffset: so, stderrOffset: se, follow: true, ...(opts.signal ? { signal: opts.signal } : {}) });
198
+ for await (const ev of events) {
199
+ if (ev.type === 'output' && ev.data !== undefined) {
200
+ const bytes = unb64(ev.data);
201
+ const start = ev.offset ?? (ev.stream === 'stderr' ? se : so);
202
+ // Skip bytes already processed (a reconnect may resend an overlapping window).
203
+ const already = (ev.stream === 'stderr' ? se : so) - start;
204
+ const fresh = already > 0 ? bytes.subarray(Math.min(already, bytes.length)) : bytes;
205
+ if (ev.stream === 'stderr') {
206
+ err.push(fresh);
207
+ se = Math.max(se, start + bytes.length);
208
+ }
209
+ else {
210
+ out.push(fresh);
211
+ so = Math.max(so, start + bytes.length);
212
+ }
213
+ if (fresh.length > 0)
214
+ opts.onOutput?.(ev.stream === 'stderr' ? 'stderr' : 'stdout', fresh);
215
+ }
216
+ else if (ev.type === 'exit') {
217
+ exited = ev.session ?? (await this.exec.get(sessionId));
218
+ break;
219
+ }
220
+ else if (ev.type === 'error' && ev.error) {
221
+ throw new ShardfluxApiError(502, ev.error, 'cell');
222
+ }
223
+ }
224
+ }
225
+ catch (e) {
226
+ if (opts.signal?.aborted)
227
+ throw e;
228
+ const transient = !(e instanceof ShardfluxApiError) || e.retryable;
229
+ if (!transient || reconnects >= maxReconnects)
230
+ throw e;
231
+ }
232
+ if (exited) {
233
+ session = exited;
234
+ break;
235
+ }
236
+ // Stream ended without `exit` (gateway restart, idle proxy, network): resume from offsets.
237
+ reconnects += 1;
238
+ if (reconnects > maxReconnects)
239
+ throw new ShardfluxProtocolError(`exec ${sessionId}: output stream kept dropping`, 0);
240
+ await this.#opts.sleep(Math.min(2_000, 100 * 2 ** reconnects));
241
+ const now = await this.exec.get(sessionId);
242
+ if (now.state !== 'starting' && now.state !== 'running' && so >= now.stdout_size && se >= now.stderr_size) {
243
+ session = now;
244
+ break;
245
+ }
246
+ }
247
+ return {
248
+ sessionId,
249
+ exitCode: session.exit_code ?? null,
250
+ termSignal: session.term_signal ?? null,
251
+ timedOut: session.timed_out ?? false,
252
+ canceled: session.canceled ?? false,
253
+ stdout: out.text(),
254
+ stderr: err.text(),
255
+ stdoutBytes: out.total,
256
+ stderrBytes: err.total,
257
+ truncated: out.total > out.kept || err.total > err.kept,
258
+ session,
259
+ reconnects,
260
+ };
261
+ }
262
+ // ---- PTY ---------------------------------------------------------------------------
263
+ pty = {
264
+ open: (req = {}) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/pty'), { json: { session_id: `pty-${randomId().replace(/-/g, '').slice(0, 24)}`, ...req } }),
265
+ get: (sessionId) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}', { session_id: sessionId })),
266
+ close: (sessionId) => this.#json('DELETE', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}', { session_id: sessionId })),
267
+ resize: (sessionId, rows, cols) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}/resize', { session_id: sessionId }), { json: { rows, cols } }),
268
+ input: (sessionId, data) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}/input', { session_id: sessionId }), { json: { data: b64(data) } }),
269
+ /** wss:// URL of the attach WebSocket (send the tool token as `Authorization: Bearer`). */
270
+ attachUrl: async (sessionId, offset = 0) => {
271
+ const token = await this.tokens.get();
272
+ const u = new URL(token.cell_endpoint + this.#p('/v1/workspaces/{workspace_id}/pty/{session_id}/attach', { session_id: sessionId }));
273
+ u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
274
+ u.searchParams.set('offset', String(offset));
275
+ return { url: u.toString(), token: token.token };
276
+ },
277
+ /**
278
+ * Reads PTY output from `offset` over the attach WebSocket until `quietMs` without output or
279
+ * `timeoutMs` in total (request/response helper for agents). Returns the next offset to resume.
280
+ */
281
+ read: async (sessionId, opts = {}) => {
282
+ const WS = globalThis.WebSocket;
283
+ if (!WS)
284
+ throw new Error('pty.read needs a global WebSocket (Node 22+); use pty.attachUrl with your own client');
285
+ const { url, token } = await this.pty.attachUrl(sessionId, opts.offset ?? 0);
286
+ const sink = new ByteSink(opts.maxBytes ?? 262_144);
287
+ let next = opts.offset ?? 0;
288
+ let session = null;
289
+ let exited = false;
290
+ await new Promise((resolve, reject) => {
291
+ const ws = new WS(url, { headers: { authorization: `Bearer ${token}` } });
292
+ let quiet;
293
+ const total = setTimeout(() => finish(), opts.timeoutMs ?? 5_000);
294
+ const finish = (err) => {
295
+ clearTimeout(total);
296
+ if (quiet)
297
+ clearTimeout(quiet);
298
+ try {
299
+ ws.close(1000);
300
+ }
301
+ catch {
302
+ // already closed
303
+ }
304
+ if (err)
305
+ reject(err);
306
+ else
307
+ resolve();
308
+ };
309
+ const arm = () => {
310
+ if (quiet)
311
+ clearTimeout(quiet);
312
+ quiet = setTimeout(() => finish(), opts.quietMs ?? 500);
313
+ };
314
+ ws.addEventListener('open', arm);
315
+ ws.addEventListener('message', (e) => {
316
+ if (typeof e.data !== 'string')
317
+ return;
318
+ const m = JSON.parse(e.data);
319
+ if (m.type === 'output' && m.data) {
320
+ const bytes = unb64(m.data);
321
+ sink.push(bytes);
322
+ next = (m.offset ?? next) + bytes.length;
323
+ arm();
324
+ }
325
+ else if (m.type === 'exit') {
326
+ session = m.session ?? null;
327
+ exited = true;
328
+ finish();
329
+ }
330
+ else if (m.type === 'error' && m.error) {
331
+ finish(new ShardfluxApiError(502, m.error, 'cell'));
332
+ }
333
+ });
334
+ ws.addEventListener('error', () => finish(new ShardfluxProtocolError('pty attach WebSocket failed', 0)));
335
+ ws.addEventListener('close', () => finish());
336
+ });
337
+ return { output: sink.text(), nextOffset: next, session, exited };
338
+ },
339
+ };
340
+ // ---- processes -----------------------------------------------------------------------
341
+ processes = {
342
+ list: () => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/processes')),
343
+ signal: async (pid, signal) => {
344
+ await this.request('POST', this.#p('/v1/workspaces/{workspace_id}/processes/{pid}/signal', { pid }), { json: { signal } });
345
+ },
346
+ };
347
+ // ---- files ---------------------------------------------------------------------------
348
+ files = {
349
+ /**
350
+ * Reads a file. Without `length` the whole file is returned: the gateway caps one read
351
+ * (FILE_MAX_READ_BYTES) and reports the size at read start in `X-File-Size`, so shorter reads
352
+ * are continued from the next offset until that size (a file that shrinks ends the loop early).
353
+ */
354
+ read: async (path, opts = {}) => {
355
+ const url = this.#p('/v1/workspaces/{workspace_id}/files');
356
+ const first = await this.request('GET', url, { query: { path, offset: opts.offset, length: opts.length }, accept: 'application/octet-stream' });
357
+ const head = new Uint8Array(await first.arrayBuffer());
358
+ const sizeHeader = first.headers.get('x-file-size');
359
+ const size = sizeHeader !== null && /^\d+$/.test(sizeHeader) ? Number(sizeHeader) : null;
360
+ const start = opts.offset ?? 0;
361
+ if (opts.length !== undefined || size === null || start + head.byteLength >= size || head.byteLength === 0)
362
+ return head;
363
+ const parts = [head];
364
+ let offset = start + head.byteLength;
365
+ while (offset < size) {
366
+ const res = await this.request('GET', url, { query: { path, offset }, accept: 'application/octet-stream' });
367
+ const chunk = new Uint8Array(await res.arrayBuffer());
368
+ if (chunk.byteLength === 0)
369
+ break;
370
+ parts.push(chunk);
371
+ offset += chunk.byteLength;
372
+ }
373
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
374
+ let at = 0;
375
+ for (const p of parts) {
376
+ out.set(p, at);
377
+ at += p.byteLength;
378
+ }
379
+ return out;
380
+ },
381
+ readText: async (path, opts = {}) => new TextDecoder().decode(await this.files.read(path, opts)),
382
+ /** Atomic replace (or append), acknowledged after fsync of file and parent directory. */
383
+ write: (path, data, opts = {}) => this.#json('PUT', this.#p('/v1/workspaces/{workspace_id}/files'), {
384
+ query: { path, mode: opts.mode, create_parents: opts.createParents, append: opts.append },
385
+ body: typeof data === 'string' ? new TextEncoder().encode(data) : data,
386
+ contentType: 'application/octet-stream',
387
+ idempotencyKey: opts.idempotencyKey ?? randomId('w-').slice(0, 40),
388
+ }),
389
+ remove: async (path, opts = {}) => {
390
+ await this.request('DELETE', this.#p('/v1/workspaces/{workspace_id}/files'), { query: { path, recursive: opts.recursive } });
391
+ },
392
+ stat: (path) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/files/stat'), { query: { path } }),
393
+ list: (path, opts = {}) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/files/list'), { query: { path, limit: opts.limit } }),
394
+ mkdir: (path, opts = {}) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/files/mkdir'), { json: { path, ...(opts.parents !== undefined ? { parents: opts.parents } : {}), ...(opts.mode !== undefined ? { mode: opts.mode } : {}) } }),
395
+ move: (from, to, opts = {}) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/files/move'), { json: { from, to, ...(opts.overwrite !== undefined ? { overwrite: opts.overwrite } : {}) } }),
396
+ };
397
+ // ---- git -------------------------------------------------------------------------------
398
+ git = {
399
+ clone: (req) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/git/clone'), { json: req, timeoutMs: (req.timeout_ms ?? 600_000) + 30_000 }),
400
+ status: (path) => this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/git/status'), { query: { path } }),
401
+ commit: (req) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/git/commit'), { json: req }),
402
+ };
403
+ // ---- browser ---------------------------------------------------------------------------
404
+ browser = {
405
+ screenshot: (req) => this.#bytes('POST', this.#p('/v1/workspaces/{workspace_id}/browser/screenshot'), { json: req, accept: 'image/png', timeoutMs: (req.timeout_ms ?? 30_000) + 15_000 }),
406
+ content: (req) => this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/browser/content'), { json: req, timeoutMs: (req.timeout_ms ?? 30_000) + 15_000 }),
407
+ };
408
+ }