@shardflux/sdk 0.14.0 → 0.16.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 +85 -23
- package/README.md +226 -7
- package/dist/account.d.ts +2 -0
- package/dist/account.js +6 -0
- package/dist/cell.d.ts +27 -0
- package/dist/cell.js +71 -0
- package/dist/client.d.ts +54 -5
- package/dist/client.js +100 -7
- package/dist/computer.d.ts +153 -0
- package/dist/computer.js +229 -0
- package/dist/errors.d.ts +5 -1
- package/dist/errors.js +9 -0
- package/dist/executions.d.ts +2 -6
- package/dist/executions.js +9 -0
- package/dist/exit-code.d.ts +7 -0
- package/dist/exit-code.js +12 -0
- package/dist/generated/app-api.d.ts +1055 -119
- package/dist/generated/cell-api.d.ts +334 -0
- package/dist/http.d.ts +7 -1
- package/dist/http.js +34 -13
- package/dist/index.d.ts +13 -6
- package/dist/index.js +7 -2
- package/dist/ports.d.ts +7 -0
- package/dist/ports.js +1 -1
- package/dist/progress.d.ts +2 -2
- package/dist/progress.js +1 -1
- package/dist/templates.d.ts +12 -0
- package/dist/templates.js +9 -0
- package/dist/testing/index.d.ts +62 -0
- package/dist/testing/index.js +585 -0
- package/dist/testing/seed.d.ts +433 -0
- package/dist/testing/seed.js +449 -0
- package/dist/tools.d.ts +21 -3
- package/dist/tools.js +113 -22
- package/dist/tunnel-assets/linux-amd64.gz +0 -0
- package/dist/tunnel-assets/linux-arm64.gz +0 -0
- package/dist/tunnel-assets.d.ts +10 -0
- package/dist/tunnel-assets.js +11 -0
- package/dist/tunnel-packet.d.ts +3 -0
- package/dist/tunnel-packet.js +43 -0
- package/dist/tunnel-pty.d.ts +86 -0
- package/dist/tunnel-pty.js +243 -0
- package/dist/tunnels.d.ts +47 -0
- package/dist/tunnels.js +454 -0
- package/dist/workspace-ref.d.ts +87 -0
- package/dist/workspace-ref.js +173 -0
- package/dist/workspace.d.ts +40 -1
- package/dist/workspace.js +111 -2
- package/package.json +7 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** In-memory transport for the real SDK. Import only from @shardflux/sdk/testing. */
|
|
2
|
+
import { Shardflux } from '../client.js';
|
|
3
|
+
import type { WorkspaceView } from '../client.js';
|
|
4
|
+
import type { ExecStartRequest } from '../cell.js';
|
|
5
|
+
export interface FakeExecResult {
|
|
6
|
+
stdout?: string;
|
|
7
|
+
stderr?: string;
|
|
8
|
+
exitCode?: number;
|
|
9
|
+
termSignal?: number;
|
|
10
|
+
timedOut?: boolean;
|
|
11
|
+
canceled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface FakeWorkspaceFaults {
|
|
14
|
+
busy?: boolean;
|
|
15
|
+
deleted?: boolean;
|
|
16
|
+
failed?: boolean;
|
|
17
|
+
legacyLayout?: boolean;
|
|
18
|
+
computerUnavailable?: boolean;
|
|
19
|
+
resizeRefusal?: 'shrink_not_supported' | 'resize_not_available' | null;
|
|
20
|
+
/** A plan clamps the requested size; it does not invent a validation error. */
|
|
21
|
+
memoryLimitMib?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface FakeShardfluxOptions {
|
|
24
|
+
/** Explicit command results. null leaves a command running until testing.completeCommand(). */
|
|
25
|
+
onExec?: (request: ExecStartRequest, workspace: WorkspaceView) => FakeExecResult | null;
|
|
26
|
+
nowMs?: number;
|
|
27
|
+
latencyMs?: number;
|
|
28
|
+
/** Replace the latency wait, e.g. advance a test runner's clock. */
|
|
29
|
+
sleep?: (ms: number) => Promise<void>;
|
|
30
|
+
quota?: 'retained_state' | 'concurrent_workspaces' | null;
|
|
31
|
+
faults?: FakeWorkspaceFaults;
|
|
32
|
+
templates?: Record<string, {
|
|
33
|
+
desktop: boolean;
|
|
34
|
+
memoryMib?: number;
|
|
35
|
+
cpuMillis?: number;
|
|
36
|
+
diskGib?: number;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
export declare class FakeUnsupportedError extends Error {
|
|
40
|
+
constructor(operation: string);
|
|
41
|
+
}
|
|
42
|
+
/** Controls are separate from the customer client surface; inspect() returns a copy. */
|
|
43
|
+
export declare class FakeShardfluxTesting {
|
|
44
|
+
#private;
|
|
45
|
+
readonly requests: {
|
|
46
|
+
method: string;
|
|
47
|
+
path: string;
|
|
48
|
+
query: string;
|
|
49
|
+
}[];
|
|
50
|
+
quota: FakeShardfluxOptions['quota'];
|
|
51
|
+
latencyMs: number;
|
|
52
|
+
constructor(options: FakeShardfluxOptions);
|
|
53
|
+
inspect(id: string): WorkspaceView;
|
|
54
|
+
setFaults(id: string, faults: FakeWorkspaceFaults): void;
|
|
55
|
+
advance(ms: number): void;
|
|
56
|
+
completeCommand(id: string, sessionId: string, result?: FakeExecResult): void;
|
|
57
|
+
readonly fetch: typeof fetch;
|
|
58
|
+
}
|
|
59
|
+
/** A real Shardflux client using an isolated in-memory HTTP transport; no request reaches the network. */
|
|
60
|
+
export declare function createFakeShardflux(options?: FakeShardfluxOptions): Shardflux & {
|
|
61
|
+
testing: FakeShardfluxTesting;
|
|
62
|
+
};
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/** In-memory transport for the real SDK. Import only from @shardflux/sdk/testing. */
|
|
2
|
+
import { Shardflux } from "../client.js";
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:os';
|
|
5
|
+
import { matchesGlob } from 'node:path';
|
|
6
|
+
import { seed } from "./seed.js";
|
|
7
|
+
export class FakeUnsupportedError extends Error {
|
|
8
|
+
constructor(operation) {
|
|
9
|
+
super(`Shardflux fake has no scripted implementation for ${operation}`);
|
|
10
|
+
this.name = 'FakeUnsupportedError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const clone = (x) => structuredClone(x);
|
|
14
|
+
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
|
|
15
|
+
const error = (status, code, message, details) => json({ error: { code, message, request_id: 'fake-request', retryable: false, ...(details ? { details } : {}) } }, status);
|
|
16
|
+
const capturedError = (body, status) => json(body, status);
|
|
17
|
+
const stamp = (ms) => new Date(ms).toISOString();
|
|
18
|
+
/** Controls are separate from the customer client surface; inspect() returns a copy. */
|
|
19
|
+
export class FakeShardfluxTesting {
|
|
20
|
+
requests = [];
|
|
21
|
+
#options;
|
|
22
|
+
#stored = new Map();
|
|
23
|
+
#operations = new Map();
|
|
24
|
+
#counter = 0;
|
|
25
|
+
#now;
|
|
26
|
+
quota;
|
|
27
|
+
latencyMs;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.#options = options;
|
|
30
|
+
this.#now = options.nowMs ?? Date.UTC(2026, 9, 4);
|
|
31
|
+
this.quota = options.quota;
|
|
32
|
+
this.latencyMs = options.latencyMs ?? 0;
|
|
33
|
+
}
|
|
34
|
+
#id() { return `00000000-0000-7000-8000-${(++this.#counter).toString(16).padStart(12, '0')}`; }
|
|
35
|
+
#get(id) {
|
|
36
|
+
const ws = this.#stored.get(id);
|
|
37
|
+
if (!ws)
|
|
38
|
+
throw new Error(`Unknown fake workspace ${id}`);
|
|
39
|
+
return ws;
|
|
40
|
+
}
|
|
41
|
+
inspect(id) { return clone(this.#get(id).view); }
|
|
42
|
+
setFaults(id, faults) {
|
|
43
|
+
const ws = this.#get(id);
|
|
44
|
+
ws.faults = { ...ws.faults, ...faults };
|
|
45
|
+
if (faults.deleted)
|
|
46
|
+
this.#delete(ws);
|
|
47
|
+
if (faults.failed)
|
|
48
|
+
ws.view.observed_state = 'failed';
|
|
49
|
+
if (faults.legacyLayout !== undefined)
|
|
50
|
+
ws.view.disk_layout = faults.legacyLayout ? 'legacy' : 'layered';
|
|
51
|
+
if (faults.computerUnavailable !== undefined) {
|
|
52
|
+
ws.view.computer_use.available = !faults.computerUnavailable;
|
|
53
|
+
ws.view.computer_use.enabled = !faults.computerUnavailable && (ws.view.computer_use.workspace ?? ws.view.computer_use.template);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
advance(ms) {
|
|
57
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
58
|
+
throw new TypeError('advance needs nonnegative milliseconds');
|
|
59
|
+
this.#now += ms;
|
|
60
|
+
for (const ws of this.#stored.values())
|
|
61
|
+
this.#evaluate(ws);
|
|
62
|
+
}
|
|
63
|
+
#busy(ws) { return ws.faults.busy === true || [...ws.commands.values()].some((c) => c.session.state === 'running') || ws.keepalive > this.#now; }
|
|
64
|
+
#evaluate(ws) {
|
|
65
|
+
if (ws.view.deleted_at !== null)
|
|
66
|
+
return;
|
|
67
|
+
if (ws.view.lifetime === 'session' && !this.#busy(ws) && this.#now >= ws.lastTool + (ws.view.idle_timeout_seconds ?? 600) * 1000) {
|
|
68
|
+
this.#delete(ws);
|
|
69
|
+
ws.view.ended_reason = 'idle_timeout';
|
|
70
|
+
}
|
|
71
|
+
else if (ws.suspendAfter !== null && !this.#busy(ws) && this.#now >= Math.max(ws.lastTool, ws.requestedAt, ws.keepalive) + ws.suspendAfter * 1000) {
|
|
72
|
+
this.#state(ws, 'suspended');
|
|
73
|
+
ws.suspendAfter = null;
|
|
74
|
+
if (ws.view.idle)
|
|
75
|
+
ws.view.idle.suspend_request = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
#state(ws, state) {
|
|
79
|
+
if (state === 'running' && ws.view.observed_state !== 'running') {
|
|
80
|
+
for (const [key, value] of ws.pending) {
|
|
81
|
+
Object.assign(ws.view.grants, { [key]: value });
|
|
82
|
+
ws.sizes[key] = value;
|
|
83
|
+
}
|
|
84
|
+
if (ws.pending.has('memory_mib')) {
|
|
85
|
+
const memory = ws.pending.get('memory_mib');
|
|
86
|
+
const mode = ws.view.caps.allocation_mode;
|
|
87
|
+
if (mode === 'elastic')
|
|
88
|
+
Object.assign(ws.view.grants, { memory_mib: ws.view.caps.memory_mib_held ?? 1024, memory_mib_max: memory, memory_mib_held: ws.view.caps.memory_mib_held ?? 1024 });
|
|
89
|
+
ws.view.memory = { allocation_mode: mode, promised_mib: memory, held_mib: mode === 'elastic' ? ws.view.caps.memory_mib_held ?? 1024 : memory, plugged_mib: mode === 'elastic' ? 0 : null };
|
|
90
|
+
}
|
|
91
|
+
ws.pending.clear();
|
|
92
|
+
}
|
|
93
|
+
ws.view.observed_state = state;
|
|
94
|
+
ws.view.desired_state = state;
|
|
95
|
+
ws.view.updated_at = stamp(this.#now);
|
|
96
|
+
}
|
|
97
|
+
#delete(ws) {
|
|
98
|
+
this.#state(ws, 'deleted');
|
|
99
|
+
ws.view.deleted_at = stamp(this.#now);
|
|
100
|
+
for (const c of ws.commands.values()) {
|
|
101
|
+
if (c.session.state === 'running')
|
|
102
|
+
this.#finish(c, { canceled: true, termSignal: 15 });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
#activity(ws) {
|
|
106
|
+
ws.lastTool = this.#now;
|
|
107
|
+
ws.suspendAfter = null;
|
|
108
|
+
if (ws.view.idle)
|
|
109
|
+
ws.view.idle.suspend_request = null;
|
|
110
|
+
}
|
|
111
|
+
#op(ws, kind, result = {}) {
|
|
112
|
+
const op = { id: this.#id(), kind, state: 'succeeded', workspace_id: ws.view.id, created_at: stamp(this.#now), updated_at: stamp(this.#now), started_at: stamp(this.#now), completed_at: stamp(this.#now), result };
|
|
113
|
+
this.#operations.set(op.id, op);
|
|
114
|
+
return op;
|
|
115
|
+
}
|
|
116
|
+
#token(ws, body) {
|
|
117
|
+
const tools = (Array.isArray(body.tools) ? body.tools : ['exec', 'files', 'processes', 'computer']).filter((t) => t !== 'computer' || ws.view.computer_use.enabled);
|
|
118
|
+
return { ...clone(seed.token), workspace_id: ws.view.id, tools, cell_endpoint: ws.view.cell_endpoint, ownership_epoch: ws.view.ownership_epoch };
|
|
119
|
+
}
|
|
120
|
+
#idle(ws) {
|
|
121
|
+
const horizons = [...ws.commands.values()].filter((c) => c.session.state === 'running').map((c) => c.until);
|
|
122
|
+
const since = Math.max(ws.lastTool, ws.keepalive, ...horizons, ws.faults.busy ? this.#now + 3600000 : 0);
|
|
123
|
+
return { ...clone(seed.idle), observed_state: ws.view.observed_state, last_tool_at: stamp(ws.lastTool), running_since: ws.view.created_at, idle_since: stamp(since), keepalive_until: ws.keepalive ? stamp(ws.keepalive) : null, suspend_request: ws.view.idle?.suspend_request ?? null, suspend_at: ws.suspendAfter === null ? null : stamp(Math.max(since, ws.requestedAt) + ws.suspendAfter * 1000) };
|
|
124
|
+
}
|
|
125
|
+
#finish(c, result) {
|
|
126
|
+
c.stdout = result.stdout ?? '';
|
|
127
|
+
c.stderr = result.stderr ?? '';
|
|
128
|
+
c.session = { ...c.session, state: 'exited', created: false, exit_code: result.exitCode ?? (result.termSignal ? -1 : 0), ...(result.termSignal ? { term_signal: result.termSignal } : {}), timed_out: result.timedOut ?? false, canceled: result.canceled ?? false, stdout_size: Buffer.byteLength(c.stdout), stderr_size: Buffer.byteLength(c.stderr), ended_at: stamp(this.#now) };
|
|
129
|
+
for (const listener of c.listeners)
|
|
130
|
+
listener();
|
|
131
|
+
}
|
|
132
|
+
completeCommand(id, sessionId, result = {}) {
|
|
133
|
+
const ws = this.#get(id);
|
|
134
|
+
const c = ws.commands.get(sessionId);
|
|
135
|
+
if (!c)
|
|
136
|
+
throw new Error(`Unknown fake command ${sessionId}`);
|
|
137
|
+
this.#finish(c, result);
|
|
138
|
+
ws.lastTool = this.#now;
|
|
139
|
+
this.#evaluate(ws);
|
|
140
|
+
}
|
|
141
|
+
#output(c, url, signal) {
|
|
142
|
+
const encoder = new TextEncoder();
|
|
143
|
+
const stream = new ReadableStream({
|
|
144
|
+
start: (controller) => {
|
|
145
|
+
let ended = false;
|
|
146
|
+
const done = () => {
|
|
147
|
+
if (ended || c.session.state === 'running')
|
|
148
|
+
return;
|
|
149
|
+
ended = true;
|
|
150
|
+
c.listeners.delete(done);
|
|
151
|
+
signal?.removeEventListener('abort', abort);
|
|
152
|
+
for (const name of ['stdout', 'stderr']) {
|
|
153
|
+
const offset = Number(url.searchParams.get(`${name}_offset`) ?? 0);
|
|
154
|
+
const data = Buffer.from(c[name]).subarray(offset);
|
|
155
|
+
if (data.length)
|
|
156
|
+
controller.enqueue(encoder.encode(JSON.stringify({ type: 'output', stream: name, offset, data: data.toString('base64') }) + '\n'));
|
|
157
|
+
}
|
|
158
|
+
controller.enqueue(encoder.encode(JSON.stringify({ type: 'exit', session: c.session }) + '\n'));
|
|
159
|
+
controller.close();
|
|
160
|
+
};
|
|
161
|
+
const abort = () => { if (!ended) {
|
|
162
|
+
ended = true;
|
|
163
|
+
c.listeners.delete(done);
|
|
164
|
+
controller.error(signal?.reason ?? new Error('aborted'));
|
|
165
|
+
} };
|
|
166
|
+
if (signal?.aborted)
|
|
167
|
+
abort();
|
|
168
|
+
else {
|
|
169
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
170
|
+
c.listeners.add(done);
|
|
171
|
+
done();
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } });
|
|
176
|
+
}
|
|
177
|
+
fetch = async (input, init) => {
|
|
178
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
179
|
+
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
|
|
180
|
+
this.requests.push({ method, path: url.pathname, query: url.search });
|
|
181
|
+
if (this.latencyMs > 0) {
|
|
182
|
+
await (this.#options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(this.latencyMs);
|
|
183
|
+
}
|
|
184
|
+
init?.signal?.throwIfAborted();
|
|
185
|
+
const body = typeof init?.body === 'string' && new Headers(init.headers).get('content-type')?.startsWith('application/json') ? JSON.parse(init.body) : {};
|
|
186
|
+
const path = url.pathname;
|
|
187
|
+
if (path === '/healthz')
|
|
188
|
+
return json({ ok: true });
|
|
189
|
+
if (path === '/v1/me')
|
|
190
|
+
return json(seed.me);
|
|
191
|
+
if (path === '/v1/workspaces/open' && method === 'POST')
|
|
192
|
+
return this.#open(body);
|
|
193
|
+
if (path === '/v1/workspaces' && method === 'GET')
|
|
194
|
+
return this.#list(url);
|
|
195
|
+
const op = /^\/v1\/operations\/(.+)$/.exec(path);
|
|
196
|
+
if (op)
|
|
197
|
+
return this.#operations.has(op[1]) ? json({ operation: this.#operations.get(op[1]) }) : error(404, 'not_found', 'Operation not found.');
|
|
198
|
+
const match = /^\/v1\/workspaces\/([^/]+)(.*)$/.exec(path);
|
|
199
|
+
if (!match)
|
|
200
|
+
throw new FakeUnsupportedError(`${method} ${path}`);
|
|
201
|
+
const ws = this.#stored.get(match[1]);
|
|
202
|
+
const tail = match[2];
|
|
203
|
+
if (!ws)
|
|
204
|
+
return error(404, 'not_found', 'Workspace not found.');
|
|
205
|
+
this.#evaluate(ws);
|
|
206
|
+
const cell = url.hostname === 'cell.fake.invalid';
|
|
207
|
+
if (ws.view.deleted_at !== null && (cell || (tail !== '' && tail !== '/delete' && tail !== '/close')))
|
|
208
|
+
return cell ? error(404, 'not_found', 'Workspace not found.') : capturedError(seed.gone, 409);
|
|
209
|
+
if (cell && ws.view.observed_state !== 'running' && tail !== '/idle')
|
|
210
|
+
return error(409, 'workspace_not_running', 'Workspace is not running.', { reason: 'workspace_not_running' });
|
|
211
|
+
if (!tail && method === 'GET')
|
|
212
|
+
return json(ws.view);
|
|
213
|
+
if (tail === '/tool-tokens')
|
|
214
|
+
return json(this.#token(ws, body), 201);
|
|
215
|
+
if (tail === '/labels' && method === 'PUT') {
|
|
216
|
+
ws.view.labels = clone(body.labels);
|
|
217
|
+
return json(ws.view);
|
|
218
|
+
}
|
|
219
|
+
if (tail === '/computer-use' && method === 'PUT') {
|
|
220
|
+
if (body.enabled && !ws.view.computer_use.available)
|
|
221
|
+
return capturedError(seed.computerUnavailable, 409);
|
|
222
|
+
ws.view.computer_use.workspace = body.enabled;
|
|
223
|
+
ws.view.computer_use.enabled = ws.view.computer_use.available && (ws.view.computer_use.workspace ?? ws.view.computer_use.template);
|
|
224
|
+
return json(ws.view.computer_use);
|
|
225
|
+
}
|
|
226
|
+
if ((!tail && method === 'DELETE') || tail === '/delete' || tail === '/close') {
|
|
227
|
+
this.#delete(ws);
|
|
228
|
+
if (tail === '/close')
|
|
229
|
+
ws.view.ended_reason = 'closed';
|
|
230
|
+
return json({ workspace: ws.view, operation: this.#op(ws, 'delete') }, 202);
|
|
231
|
+
}
|
|
232
|
+
if (tail === '/resume') {
|
|
233
|
+
this.#state(ws, 'running');
|
|
234
|
+
this.#activity(ws);
|
|
235
|
+
return json({ workspace: ws.view, operation: this.#op(ws, 'resume'), tool_token: this.#token(ws, body) }, 200, { 'preference-applied': 'wait=20' });
|
|
236
|
+
}
|
|
237
|
+
if (tail === '/suspend' || tail === '/suspend-when-idle')
|
|
238
|
+
return this.#suspend(ws, tail, method, body);
|
|
239
|
+
if (tail === '/caps' && method === 'PATCH')
|
|
240
|
+
return this.#resize(ws, body);
|
|
241
|
+
if (tail === '/wake-hint')
|
|
242
|
+
return json({ residency: 'resident' });
|
|
243
|
+
if (tail === '/idle')
|
|
244
|
+
return json(this.#idle(ws));
|
|
245
|
+
if (tail === '/keepalive') {
|
|
246
|
+
ws.keepalive = Math.max(ws.keepalive, this.#now + Number(body.seconds) * 1000);
|
|
247
|
+
return json({ keepalive_until: stamp(ws.keepalive) });
|
|
248
|
+
}
|
|
249
|
+
if (tail.startsWith('/exec'))
|
|
250
|
+
return this.#exec(ws, tail, method, body, url, init);
|
|
251
|
+
if (tail.startsWith('/files')) {
|
|
252
|
+
this.#activity(ws);
|
|
253
|
+
return this.#files(ws, tail, method, body, url, init);
|
|
254
|
+
}
|
|
255
|
+
if (tail.startsWith('/computer')) {
|
|
256
|
+
this.#activity(ws);
|
|
257
|
+
return this.#computer(ws, tail, method, body);
|
|
258
|
+
}
|
|
259
|
+
if (tail.startsWith('/ports'))
|
|
260
|
+
return this.#ports(ws, tail, method, body);
|
|
261
|
+
throw new FakeUnsupportedError(`${method} ${path}`);
|
|
262
|
+
};
|
|
263
|
+
#open(body) {
|
|
264
|
+
if (body.retention !== undefined)
|
|
265
|
+
throw new FakeUnsupportedError('open.retention (extend from a live capture)');
|
|
266
|
+
let ws = [...this.#stored.values()].find((s) => s.view.workspace_key === body.key && s.view.deleted_at === null);
|
|
267
|
+
const created = !ws;
|
|
268
|
+
if (this.quota && (!ws || this.quota === 'concurrent_workspaces'))
|
|
269
|
+
return error(403, 'quota_exceeded', 'The plan limit has been reached.', { limit: this.quota, limit_value: 1, current: 1, plan: 'free' });
|
|
270
|
+
if (ws) {
|
|
271
|
+
if (body.lifetime && body.lifetime !== ws.view.lifetime)
|
|
272
|
+
return error(409, 'conflict', 'Workspace lifetime differs.', { reason: 'lifetime_mismatch' });
|
|
273
|
+
this.#state(ws, 'running');
|
|
274
|
+
this.#activity(ws);
|
|
275
|
+
if (body.labels)
|
|
276
|
+
ws.view.labels = clone(body.labels);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
if (body.mode === 'file_first')
|
|
280
|
+
throw new FakeUnsupportedError('file_first workspaces');
|
|
281
|
+
const templates = this.#options.templates ?? { 'python-node-browser': { desktop: true }, 'ubuntu-24.04': { desktop: false }, default: { desktop: true } };
|
|
282
|
+
const template = templates[String(body.template)];
|
|
283
|
+
if (!template)
|
|
284
|
+
throw new FakeUnsupportedError(`template ${String(body.template)} (configure options.templates)`);
|
|
285
|
+
const view = clone(seed.workspace);
|
|
286
|
+
view.id = this.#id();
|
|
287
|
+
view.workspace_key = String(body.key);
|
|
288
|
+
view.labels = clone((body.labels ?? {}));
|
|
289
|
+
view.created_at = view.updated_at = stamp(this.#now);
|
|
290
|
+
view.project_id = body.project_id ?? view.project_id;
|
|
291
|
+
const slug = body.template === 'default' ? 'python-node-browser' : String(body.template);
|
|
292
|
+
view.template = clone(seed.templateViews[slug] ?? view.template);
|
|
293
|
+
view.template.slug = slug;
|
|
294
|
+
view.template_version_id = view.template.version_id;
|
|
295
|
+
view.lifetime = body.lifetime === 'session' ? 'session' : 'persistent';
|
|
296
|
+
view.idle_timeout_seconds = view.lifetime === 'session' ? 600 : null;
|
|
297
|
+
view.idle = view.lifetime === 'session' ? null : { policy: 'adaptive', source: 'default', mode: 'adaptive', timeout_seconds: null, basis: null, next_eligible_at: null, suspend_request: null };
|
|
298
|
+
view.caps = { ...clone(seed.defaultWorkspace.caps), ...(body.caps ?? {}) };
|
|
299
|
+
if (view.caps.allocation_mode === 'fixed')
|
|
300
|
+
delete view.caps.memory_mib_held;
|
|
301
|
+
const memory = view.caps.memory_mib ?? template.memoryMib ?? 8192;
|
|
302
|
+
const held = view.caps.allocation_mode === 'elastic' ? view.caps.memory_mib_held ?? 1024 : memory;
|
|
303
|
+
view.memory = { allocation_mode: view.caps.allocation_mode, promised_mib: memory, held_mib: held, plugged_mib: view.caps.allocation_mode === 'elastic' ? 0 : null };
|
|
304
|
+
view.grants = { ...clone(view.caps.allocation_mode === 'elastic' ? seed.defaultWorkspace.grants : seed.workspace.grants), memory_mib: held, ...(view.caps.allocation_mode === 'elastic' ? { memory_mib_max: memory, memory_mib_held: held } : {}), cpu_millis: view.caps.cpu_millis ?? template.cpuMillis ?? 4000, disk_gib: view.caps.disk_gib ?? template.diskGib ?? 20, allocation_mode: view.caps.allocation_mode };
|
|
305
|
+
view.ceilings = { cpu_millis: Number(view.grants.cpu_millis), memory_mib: memory, disk_gib: Number(view.grants.disk_gib), sources: { cpu_millis: view.caps.cpu_millis === null ? 'plan' : 'user', memory_mib: view.caps.memory_mib === null ? 'plan' : 'user', disk_gib: view.caps.disk_gib === null ? 'plan' : 'user' }, resolved_at: stamp(this.#now) };
|
|
306
|
+
view.computer_use = { available: template.desktop, workspace: body.computer_use ?? null, template: false, enabled: template.desktop && body.computer_use === true };
|
|
307
|
+
if (body.computer_use && !template.desktop)
|
|
308
|
+
return capturedError(seed.computerUnavailable, 409);
|
|
309
|
+
ws = { view, files: new Map(), dirs: new Set(['/home/user', '/tmp', '/']), commands: new Map(), faults: { ...this.#options.faults }, lastTool: this.#now, keepalive: 0, suspendAfter: null, requestedAt: 0, computerRunning: false, streams: { view: false, interactive: false }, ports: new Map(), pending: new Map(), sizes: { cpu_millis: Number(view.grants.cpu_millis), disk_gib: Number(view.grants.disk_gib) } };
|
|
310
|
+
this.#stored.set(view.id, ws);
|
|
311
|
+
this.setFaults(view.id, ws.faults);
|
|
312
|
+
}
|
|
313
|
+
return json({ workspace: ws.view, operation: null, tool_token: this.#token(ws, body), created });
|
|
314
|
+
}
|
|
315
|
+
#list(url) {
|
|
316
|
+
const q = url.searchParams;
|
|
317
|
+
const labels = JSON.parse(q.get('labels') ?? '{}');
|
|
318
|
+
const all = [...this.#stored.values()].filter(({ view: v }) => (q.get('include_deleted') === 'true' || !v.deleted_at) && v.workspace_key.startsWith(q.get('key_prefix') ?? '') && (q.get('lifetime') === 'any' || v.lifetime === (q.get('lifetime') ?? 'persistent')) && (q.get('purpose') === 'any' || v.purpose === (q.get('purpose') ?? 'standard')) && (!q.get('state') || v.observed_state === q.get('state')) && (!q.get('desired_state') || v.desired_state === q.get('desired_state')) && (!q.get('project_id') || v.project_id === q.get('project_id')));
|
|
319
|
+
// All supplied labels match exactly; page cursors are opaque to customers.
|
|
320
|
+
const filtered = all.filter(({ view }) => Object.entries(labels).every(([k, v]) => view.labels[k] === v));
|
|
321
|
+
const offset = Number(q.get('cursor') ?? 0);
|
|
322
|
+
const limit = Number(q.get('limit') ?? 100);
|
|
323
|
+
return json({ data: filtered.slice(offset, offset + limit).map((s) => s.view), next_cursor: offset + limit < filtered.length ? String(offset + limit) : null });
|
|
324
|
+
}
|
|
325
|
+
#suspend(ws, tail, method, body) {
|
|
326
|
+
if (method === 'DELETE') {
|
|
327
|
+
ws.suspendAfter = null;
|
|
328
|
+
if (ws.view.idle)
|
|
329
|
+
ws.view.idle.suspend_request = null;
|
|
330
|
+
return json(ws.view);
|
|
331
|
+
}
|
|
332
|
+
if (ws.view.lifetime === 'session')
|
|
333
|
+
return capturedError(seed.sessionLifetime, 409);
|
|
334
|
+
if (tail === '/suspend') {
|
|
335
|
+
this.#state(ws, 'suspended');
|
|
336
|
+
return json({ operation: this.#op(ws, 'suspend', { durable: true }), workspace: ws.view }, 202);
|
|
337
|
+
}
|
|
338
|
+
const seconds = Number(body.after_seconds);
|
|
339
|
+
if (!Number.isInteger(seconds) || seconds < 0 || seconds > 3600)
|
|
340
|
+
return error(422, 'validation_failed', 'after_seconds must be between 0 and 3600.', { field: 'after_seconds' });
|
|
341
|
+
if (ws.view.observed_state !== 'running')
|
|
342
|
+
return error(409, 'conflict', 'Workspace is not running.', { reason: 'not_running' });
|
|
343
|
+
ws.suspendAfter = seconds;
|
|
344
|
+
ws.requestedAt = this.#now;
|
|
345
|
+
const request = { requested_at: stamp(this.#now), after_seconds: seconds, not_before: stamp(this.#now + seconds * 1000) };
|
|
346
|
+
if (ws.view.idle)
|
|
347
|
+
ws.view.idle.suspend_request = request;
|
|
348
|
+
this.#evaluate(ws);
|
|
349
|
+
const suspended = ws.view.desired_state === 'suspended';
|
|
350
|
+
return json({ workspace: ws.view, suspend_request: suspended ? null : request, operation: suspended ? this.#op(ws, 'suspend', { durable: true }) : null }, 202);
|
|
351
|
+
}
|
|
352
|
+
#resize(ws, body) {
|
|
353
|
+
if (body.at_least === true)
|
|
354
|
+
throw new FakeUnsupportedError('resize mode at_least (extend from a live capture)');
|
|
355
|
+
if (ws.faults.resizeRefusal === 'resize_not_available')
|
|
356
|
+
return error(409, 'conflict', 'Resize is not enabled.', { reason: 'resize_not_available' });
|
|
357
|
+
const currentDisk = ws.sizes.disk_gib ?? 20;
|
|
358
|
+
if (ws.faults.resizeRefusal === 'shrink_not_supported' || (body.disk_gib !== undefined && Number(body.disk_gib) < currentDisk))
|
|
359
|
+
return error(422, 'validation_failed', `The disk can only grow: it is ${currentDisk} GiB.`, { field: 'disk_gib', reason: 'shrink_not_supported', current_disk_gib: currentDisk });
|
|
360
|
+
const out = { workspace_id: ws.view.id, operation_id: this.#id(), state: ws.view.observed_state, memory: null, cpu: null, disk: null };
|
|
361
|
+
const at = ws.view.observed_state === 'suspended' ? 'resume' : ws.faults.legacyLayout ? 'next_start' : 'now';
|
|
362
|
+
const reason = at === 'resume' ? 'suspended' : at === 'next_start' ? 'legacy_layout' : null;
|
|
363
|
+
const mode = body.allocation_mode ?? ws.view.caps.allocation_mode;
|
|
364
|
+
if (body.memory_mib_held !== undefined && mode !== 'elastic')
|
|
365
|
+
return error(422, 'validation_failed', 'memory_mib_held requires elastic memory.', { field: 'memory_mib_held', reason: 'requires_elastic' });
|
|
366
|
+
const memory = Number(body.memory_mib ?? ws.view.memory.promised_mib);
|
|
367
|
+
if (body.memory_mib_held !== undefined && Number(body.memory_mib_held) > memory)
|
|
368
|
+
return error(422, 'validation_failed', 'The held floor exceeds maximum memory.', { field: 'memory_mib_held', reason: 'exceeds_memory_mib', memory_mib: memory });
|
|
369
|
+
for (const [resource, key, suffix] of [['memory', 'memory_mib', 'mib'], ['cpu', 'cpu_millis', 'millis'], ['disk', 'disk_gib', 'gib']]) {
|
|
370
|
+
if (body[key] === undefined && !(resource === 'memory' && (body.allocation_mode !== undefined || body.memory_mib_held !== undefined)))
|
|
371
|
+
continue;
|
|
372
|
+
const requested = Number(body[key] ?? ws.view.memory.promised_mib);
|
|
373
|
+
const previous = resource === 'memory' ? ws.view.memory.promised_mib : ws.sizes[key];
|
|
374
|
+
const target = resource === 'memory' ? Math.min(requested, ws.faults.memoryLimitMib ?? requested) : requested;
|
|
375
|
+
const part = { applies_at: at, [`requested_${suffix}`]: requested, [`target_${suffix}`]: target, [`previous_${suffix}`]: previous, [`applied_${suffix}`]: at === 'now' ? target : at === 'resume' ? null : previous, limit_reason: target < requested ? 'plan' : null, reason };
|
|
376
|
+
if (resource === 'memory')
|
|
377
|
+
Object.assign(part, { allocation_mode: mode, held_mib: mode === 'elastic' ? body.memory_mib_held ?? 1024 : null, converged: at === 'now' ? true : null });
|
|
378
|
+
out[resource] = part;
|
|
379
|
+
if (body[key] !== undefined)
|
|
380
|
+
Object.assign(ws.view.caps, { [key]: requested });
|
|
381
|
+
if (at === 'now')
|
|
382
|
+
ws.sizes[key] = target; // grants are the captured report of the last start, not current memory.
|
|
383
|
+
if (at === 'resume')
|
|
384
|
+
ws.pending.set(key, target);
|
|
385
|
+
if (ws.view.ceilings) {
|
|
386
|
+
Object.assign(ws.view.ceilings, { [key]: target });
|
|
387
|
+
Object.assign(ws.view.ceilings.sources, { [key]: target < requested ? 'plan' : 'user' });
|
|
388
|
+
ws.view.ceilings.resolved_at = stamp(this.#now);
|
|
389
|
+
}
|
|
390
|
+
if (resource === 'memory' && at === 'now')
|
|
391
|
+
ws.view.memory = { allocation_mode: mode, promised_mib: target, held_mib: mode === 'elastic' ? Number(body.memory_mib_held ?? 1024) : target, plugged_mib: mode === 'elastic' ? 0 : null };
|
|
392
|
+
}
|
|
393
|
+
ws.view.caps.allocation_mode = mode;
|
|
394
|
+
if (body.memory_mib_held !== undefined)
|
|
395
|
+
ws.view.caps.memory_mib_held = Number(body.memory_mib_held);
|
|
396
|
+
return json({ ...out, caps: ws.view.caps });
|
|
397
|
+
}
|
|
398
|
+
#exec(ws, tail, method, body, url, init) {
|
|
399
|
+
if (tail === '/exec' && method === 'POST') {
|
|
400
|
+
this.#activity(ws);
|
|
401
|
+
const req = body;
|
|
402
|
+
req.session_id ??= `fake-exec-${this.#id()}`;
|
|
403
|
+
let c = ws.commands.get(req.session_id);
|
|
404
|
+
if (!c) {
|
|
405
|
+
if (!this.#options.onExec)
|
|
406
|
+
throw new FakeUnsupportedError('exec (configure options.onExec)');
|
|
407
|
+
const result = this.#options.onExec(clone(req), clone(ws.view));
|
|
408
|
+
c = { session: { ...clone(seed.execSession), session_id: req.session_id, created: true, state: 'running', exit_code: null, stdout_size: 0, stderr_size: 0, created_at: stamp(this.#now), started_at: stamp(this.#now), ended_at: undefined }, stdout: '', stderr: '', until: this.#now + (req.timeout_ms ?? 3600000), listeners: new Set() };
|
|
409
|
+
ws.commands.set(req.session_id, c);
|
|
410
|
+
if (result !== null)
|
|
411
|
+
this.#finish(c, result);
|
|
412
|
+
}
|
|
413
|
+
return new Headers(init?.headers).get('accept')?.includes('ndjson') ? this.#output(c, url, init?.signal) : json(c.session, 201);
|
|
414
|
+
}
|
|
415
|
+
const match = /^\/exec\/([^/]+)(.*)$/.exec(tail);
|
|
416
|
+
const c = match && ws.commands.get(match[1]);
|
|
417
|
+
if (!c)
|
|
418
|
+
return error(404, 'not_found', 'Exec session not found.');
|
|
419
|
+
const action = match[2];
|
|
420
|
+
if (!action)
|
|
421
|
+
return json(c.session);
|
|
422
|
+
if (action === '/output')
|
|
423
|
+
return this.#output(c, url, init?.signal);
|
|
424
|
+
if (action === '/cancel' || action === '/signal') {
|
|
425
|
+
const sig = action === '/cancel' ? 15 : typeof body.signal === 'string' ? constants.signals[body.signal] : Number(body.signal);
|
|
426
|
+
if (!sig)
|
|
427
|
+
return error(422, 'validation_failed', 'Unknown signal.', { field: 'signal' });
|
|
428
|
+
this.#finish(c, { termSignal: sig, canceled: action === '/cancel' });
|
|
429
|
+
ws.lastTool = this.#now;
|
|
430
|
+
this.#evaluate(ws);
|
|
431
|
+
return json(c.session);
|
|
432
|
+
}
|
|
433
|
+
throw new FakeUnsupportedError(`${method} ${tail}`);
|
|
434
|
+
}
|
|
435
|
+
#files(ws, tail, method, body, url, init) {
|
|
436
|
+
const path = url.searchParams.get('path') ?? body.path ?? '';
|
|
437
|
+
const info = (p) => ({ ...clone(seed.fileInfo), path: p, name: p.split('/').at(-1), size: ws.files.get(p)?.length ?? 0, type: ws.dirs.has(p) ? 'directory' : 'file', modified_at: stamp(this.#now), ...(url.searchParams.get('revision') === 'true' && ws.files.has(p) ? { revision: createHash('sha256').update(ws.files.get(p)).digest('hex') } : { revision: undefined }) });
|
|
438
|
+
if (tail === '/files' && method === 'GET') {
|
|
439
|
+
const data = ws.files.get(path);
|
|
440
|
+
if (!data)
|
|
441
|
+
return error(404, 'not_found', `open ${path}: no such file or directory`, { reason: 'path_not_found' });
|
|
442
|
+
const offset = Number(url.searchParams.get('offset') ?? 0);
|
|
443
|
+
const length = Number(url.searchParams.get('length') ?? data.length);
|
|
444
|
+
return new Response(data.slice(offset, offset + length), { headers: { 'content-type': 'application/octet-stream' } });
|
|
445
|
+
}
|
|
446
|
+
if (tail === '/files' && method === 'PUT') {
|
|
447
|
+
const raw = init?.body;
|
|
448
|
+
const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : raw instanceof Uint8Array ? new Uint8Array(raw) : new Uint8Array();
|
|
449
|
+
ws.files.set(path, url.searchParams.get('append') === 'true' ? new Uint8Array([...ws.files.get(path) ?? [], ...bytes]) : bytes);
|
|
450
|
+
return json({ ...clone(seed.fileWrite), path, bytes_written: bytes.length, file: info(path), sha256: createHash('sha256').update(ws.files.get(path)).digest('hex') });
|
|
451
|
+
}
|
|
452
|
+
if (tail === '/files' && method === 'DELETE') {
|
|
453
|
+
ws.files.delete(path);
|
|
454
|
+
ws.dirs.delete(path);
|
|
455
|
+
return new Response(null, { status: 204 });
|
|
456
|
+
}
|
|
457
|
+
if (tail === '/files/stat')
|
|
458
|
+
return ws.files.has(path) || ws.dirs.has(path) ? json(info(path)) : error(404, 'not_found', `stat ${path}: no such file or directory`, { reason: 'path_not_found' });
|
|
459
|
+
if (tail === '/files/list')
|
|
460
|
+
return json({ ...clone(seed.fileList), path, entries: [...ws.files.keys(), ...ws.dirs].filter((p) => p !== path && p.slice(0, p.lastIndexOf('/')) === path.replace(/\/$/, '')).map(info) });
|
|
461
|
+
if (tail === '/files/mkdir') {
|
|
462
|
+
ws.dirs.add(path);
|
|
463
|
+
return json(info(path));
|
|
464
|
+
}
|
|
465
|
+
if (tail === '/files/move') {
|
|
466
|
+
const from = String(body.from);
|
|
467
|
+
const to = String(body.to);
|
|
468
|
+
const data = ws.files.get(from);
|
|
469
|
+
if (!data)
|
|
470
|
+
return error(404, 'not_found', `open ${from}: no such file or directory`, { reason: 'path_not_found' });
|
|
471
|
+
ws.files.set(to, data);
|
|
472
|
+
ws.files.delete(from);
|
|
473
|
+
return json(info(to));
|
|
474
|
+
}
|
|
475
|
+
if (tail === '/files/patch') {
|
|
476
|
+
const current = ws.files.get(path);
|
|
477
|
+
const previous = current ? createHash('sha256').update(current).digest('hex') : 'absent';
|
|
478
|
+
if (body.expected_revision !== undefined && body.expected_revision !== previous)
|
|
479
|
+
return error(409, 'conflict', 'The file revision changed.', { reason: 'revision_mismatch', current_revision: previous });
|
|
480
|
+
if (body.edits !== undefined && !current)
|
|
481
|
+
return error(404, 'not_found', `open ${path}: no such file or directory`, { reason: 'path_not_found' });
|
|
482
|
+
let text = typeof body.content === 'string' ? body.content : new TextDecoder().decode(current);
|
|
483
|
+
let replacements = 0;
|
|
484
|
+
for (const [index, edit] of (body.edits ?? []).entries()) {
|
|
485
|
+
const count = edit.old_text ? text.split(edit.old_text).length - 1 : 0;
|
|
486
|
+
if (!count)
|
|
487
|
+
return error(422, 'validation_failed', 'The edit text was not found.', { reason: 'edit_not_found', index });
|
|
488
|
+
if (count > 1 && !edit.replace_all)
|
|
489
|
+
return error(422, 'validation_failed', 'The edit text occurs more than once.', { reason: 'edit_ambiguous', index });
|
|
490
|
+
text = edit.replace_all ? text.split(edit.old_text).join(edit.new_text) : text.replace(edit.old_text, () => edit.new_text);
|
|
491
|
+
replacements += edit.replace_all ? count : 1;
|
|
492
|
+
}
|
|
493
|
+
const data = new TextEncoder().encode(text);
|
|
494
|
+
ws.files.set(path, data);
|
|
495
|
+
return json({ ...clone(seed.filePatch), path, previous_revision: previous, revision: createHash('sha256').update(data).digest('hex'), bytes_written: data.length, ...(body.edits !== undefined ? { replacements } : { replacements: undefined }), file: info(path) });
|
|
496
|
+
}
|
|
497
|
+
if (tail === '/files/search') {
|
|
498
|
+
const matches = [];
|
|
499
|
+
let scanned = 0;
|
|
500
|
+
const root = path.replace(/\/$/, '');
|
|
501
|
+
const max = Number(body.max_matches ?? 200);
|
|
502
|
+
const pattern = body.pattern;
|
|
503
|
+
const regex = body.regex ? new RegExp(pattern, body.case_insensitive ? 'i' : '') : null;
|
|
504
|
+
for (const [p, data] of [...ws.files].sort(([a], [b]) => a.localeCompare(b))) {
|
|
505
|
+
if (p !== root && !p.startsWith(root + '/'))
|
|
506
|
+
continue;
|
|
507
|
+
const include = body.include;
|
|
508
|
+
const exclude = body.exclude;
|
|
509
|
+
if (include && !include.some((g) => matchesGlob(p, g)))
|
|
510
|
+
continue;
|
|
511
|
+
if ((exclude ?? ['**/.git/**', '**/node_modules/**']).some((g) => matchesGlob(p, g)))
|
|
512
|
+
continue;
|
|
513
|
+
if (data.length > Number(body.max_file_bytes ?? 1048576) || data.includes(0))
|
|
514
|
+
continue;
|
|
515
|
+
scanned += 1;
|
|
516
|
+
const lines = new TextDecoder().decode(data).split('\n');
|
|
517
|
+
for (const [index, text] of lines.entries()) {
|
|
518
|
+
const at = regex ? text.search(regex) : (body.case_insensitive ? text.toLowerCase().indexOf(pattern.toLowerCase()) : text.indexOf(pattern));
|
|
519
|
+
if (at < 0)
|
|
520
|
+
continue;
|
|
521
|
+
const context = Number(body.context_lines ?? 0);
|
|
522
|
+
matches.push({ path: p, line: index + 1, column: Buffer.byteLength(text.slice(0, at)) + 1, text: Buffer.from(text).subarray(0, 1000).toString(), ...(context ? { before: lines.slice(Math.max(0, index - context), index), after: lines.slice(index + 1, index + 1 + context) } : {}) });
|
|
523
|
+
if (matches.length >= max)
|
|
524
|
+
return json({ matches, truncated: true, stop_reason: 'max_matches', files_scanned: scanned });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return json({ matches, truncated: false, files_scanned: scanned });
|
|
528
|
+
}
|
|
529
|
+
throw new FakeUnsupportedError(`${method} ${tail}`);
|
|
530
|
+
}
|
|
531
|
+
#computer(ws, tail, method, body) {
|
|
532
|
+
if (!ws.view.computer_use.available)
|
|
533
|
+
return error(409, 'conflict', 'This template has no desktop runtime.', { reason: 'computer_use_unavailable' });
|
|
534
|
+
if (!ws.view.computer_use.enabled)
|
|
535
|
+
return error(409, 'conflict', 'Computer use is disabled.', { reason: 'computer_use_disabled' });
|
|
536
|
+
if (tail === '/computer/actions') {
|
|
537
|
+
ws.computerRunning = true;
|
|
538
|
+
const actions = body.actions;
|
|
539
|
+
const image = clone(seed.computerActions.screenshot);
|
|
540
|
+
return json({ ...clone(seed.computerActions), results: actions.map((a) => ({ action: a.action, ok: true, ...(['screenshot', 'zoom'].includes(a.action) ? { image } : {}) })), ...(body.screenshot === false ? { screenshot: undefined } : { screenshot: image }) });
|
|
541
|
+
}
|
|
542
|
+
if (tail === '/computer/stream') {
|
|
543
|
+
if (method === 'DELETE') {
|
|
544
|
+
ws.streams = { view: false, interactive: false };
|
|
545
|
+
return new Response(null, { status: 204 });
|
|
546
|
+
}
|
|
547
|
+
ws.computerRunning = true;
|
|
548
|
+
const interactive = body.interactive === true;
|
|
549
|
+
ws.streams[interactive ? 'interactive' : 'view'] = true;
|
|
550
|
+
return json({ port: interactive ? 61003 : 61002, path: '/view', interactive });
|
|
551
|
+
}
|
|
552
|
+
if (method === 'DELETE') {
|
|
553
|
+
ws.computerRunning = false;
|
|
554
|
+
return new Response(null, { status: 204 });
|
|
555
|
+
}
|
|
556
|
+
if (method === 'POST')
|
|
557
|
+
ws.computerRunning = true;
|
|
558
|
+
return json(ws.computerRunning ? { ...clone(seed.computerStatus), streams: ws.streams } : clone(seed.computerStopped));
|
|
559
|
+
}
|
|
560
|
+
#ports(ws, tail, method, body) {
|
|
561
|
+
if (tail === '/ports' && method === 'GET')
|
|
562
|
+
return json({ ports: [...ws.ports.values()].sort((a, b) => Number(a.port) - Number(b.port)) });
|
|
563
|
+
const match = /^\/ports\/(\d+)(.*)$/.exec(tail);
|
|
564
|
+
if (!match)
|
|
565
|
+
throw new FakeUnsupportedError(`${method} ${tail}`);
|
|
566
|
+
const port = Number(match[1]);
|
|
567
|
+
const url = `https://${port}-fake.shardflux.invalid`;
|
|
568
|
+
if (match[2] === '/links' && body.embed)
|
|
569
|
+
throw new FakeUnsupportedError('embedded port links (extend from a live capture)');
|
|
570
|
+
if (match[2] === '/links')
|
|
571
|
+
return json({ url: `${url}${body.path ?? '/'}`, expires_at: stamp(this.#now + Number(body.ttl_seconds ?? 86400) * 1000), port }, 201);
|
|
572
|
+
if (method === 'DELETE') {
|
|
573
|
+
ws.ports.delete(port);
|
|
574
|
+
return new Response(null, { status: 204 });
|
|
575
|
+
}
|
|
576
|
+
const view = { port, url, created_at: stamp(this.#now), closed_at: null };
|
|
577
|
+
ws.ports.set(port, view);
|
|
578
|
+
return json(view, 201);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/** A real Shardflux client using an isolated in-memory HTTP transport; no request reaches the network. */
|
|
582
|
+
export function createFakeShardflux(options = {}) {
|
|
583
|
+
const testing = new FakeShardfluxTesting(options);
|
|
584
|
+
return Object.assign(new Shardflux({ apiKey: 'sfk_aaaaaaaaaaaaaaaa_fakeSecret', baseUrl: 'https://api.fake.invalid', fetch: testing.fetch, versionCheck: false, maxRetries: 0 }), { testing });
|
|
585
|
+
}
|