veryfront 0.1.274 → 0.1.276
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/esm/deno.js +1 -1
- package/esm/src/agent/agent-service.d.ts +11 -0
- package/esm/src/agent/agent-service.d.ts.map +1 -1
- package/esm/src/agent/index.d.ts +1 -1
- package/esm/src/agent/index.d.ts.map +1 -1
- package/esm/src/sandbox/index.d.ts +1 -0
- package/esm/src/sandbox/index.d.ts.map +1 -1
- package/esm/src/sandbox/index.js +1 -0
- package/esm/src/sandbox/lazy-sandbox.d.ts +57 -0
- package/esm/src/sandbox/lazy-sandbox.d.ts.map +1 -0
- package/esm/src/sandbox/lazy-sandbox.js +403 -0
- package/esm/src/sandbox/sandbox.d.ts +12 -0
- package/esm/src/sandbox/sandbox.d.ts.map +1 -1
- package/esm/src/sandbox/sandbox.js +45 -29
- package/esm/src/utils/version-constant.d.ts +1 -1
- package/esm/src/utils/version-constant.js +1 -1
- package/package.json +1 -1
- package/src/deno.js +1 -1
- package/src/src/agent/agent-service.ts +12 -0
- package/src/src/agent/index.ts +1 -0
- package/src/src/sandbox/index.ts +1 -0
- package/src/src/sandbox/lazy-sandbox.ts +504 -0
- package/src/src/sandbox/sandbox.ts +61 -32
- package/src/src/utils/version-constant.ts +1 -1
|
@@ -10,6 +10,21 @@ import * as dntShim from "../../_dnt.shims.js";
|
|
|
10
10
|
import { createError, INITIALIZATION_ERROR, REQUEST_ERROR, TIMEOUT_ERROR, toError, } from "../errors/index.js";
|
|
11
11
|
import { getVeryfrontCloudAuthToken } from "../platform/cloud/resolver.js";
|
|
12
12
|
import { getHostEnv } from "../platform/compat/process.js";
|
|
13
|
+
import { LazySandbox } from "./lazy-sandbox.js";
|
|
14
|
+
export function resolveSandboxApiUrl(options = {}) {
|
|
15
|
+
return options.apiUrl ||
|
|
16
|
+
getHostEnv("VERYFRONT_API_URL") ||
|
|
17
|
+
"https://api.veryfront.com";
|
|
18
|
+
}
|
|
19
|
+
export function resolveSandboxAuthToken(options = {}) {
|
|
20
|
+
const authToken = options.authToken?.trim() || getVeryfrontCloudAuthToken();
|
|
21
|
+
if (authToken)
|
|
22
|
+
return authToken;
|
|
23
|
+
throw toError(createError({
|
|
24
|
+
type: "config",
|
|
25
|
+
message: "Sandbox auth not configured. Set VERYFRONT_API_TOKEN, provide request-scoped Veryfront credentials, or pass authToken explicitly.",
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
13
28
|
/** Client for isolated ephemeral compute environments with command execution and file I/O. */
|
|
14
29
|
export class Sandbox {
|
|
15
30
|
endpoint;
|
|
@@ -23,18 +38,10 @@ export class Sandbox {
|
|
|
23
38
|
this.apiUrl = apiUrl;
|
|
24
39
|
}
|
|
25
40
|
static resolveApiUrl(options = {}) {
|
|
26
|
-
return options
|
|
27
|
-
getHostEnv("VERYFRONT_API_URL") ||
|
|
28
|
-
"https://api.veryfront.com";
|
|
41
|
+
return resolveSandboxApiUrl(options);
|
|
29
42
|
}
|
|
30
43
|
static resolveAuthToken(options = {}) {
|
|
31
|
-
|
|
32
|
-
if (authToken)
|
|
33
|
-
return authToken;
|
|
34
|
-
throw toError(createError({
|
|
35
|
-
type: "config",
|
|
36
|
-
message: "Sandbox auth not configured. Set VERYFRONT_API_TOKEN, provide request-scoped Veryfront credentials, or pass authToken explicitly.",
|
|
37
|
-
}));
|
|
44
|
+
return resolveSandboxAuthToken(options);
|
|
38
45
|
}
|
|
39
46
|
/** Create a new sandbox session. Claims a warm pod or creates a new one. */
|
|
40
47
|
static async create(options = {}) {
|
|
@@ -112,25 +119,11 @@ export class Sandbox {
|
|
|
112
119
|
};
|
|
113
120
|
}
|
|
114
121
|
static async waitForReady(apiUrl, id, authToken, maxWaitMs = 60_000, pollIntervalMs = 2_000) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
headers: { Authorization: `Bearer ${authToken}` },
|
|
121
|
-
});
|
|
122
|
-
if (res.ok) {
|
|
123
|
-
const data = await res.json();
|
|
124
|
-
if (data.status === "running")
|
|
125
|
-
return;
|
|
126
|
-
if (data.status === "error" || data.status === "deleting") {
|
|
127
|
-
throw INITIALIZATION_ERROR.create({
|
|
128
|
-
detail: `Sandbox failed to start: status=${data.status}`,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
throw TIMEOUT_ERROR.create({ detail: "Sandbox did not become ready within timeout" });
|
|
122
|
+
await waitForSandboxReady({ apiUrl, id, authToken, maxWaitMs, pollIntervalMs });
|
|
123
|
+
}
|
|
124
|
+
/** Create a lazily-provisioned sandbox session with automatic heartbeats. */
|
|
125
|
+
static createLazy(options = {}) {
|
|
126
|
+
return new LazySandbox(options);
|
|
134
127
|
}
|
|
135
128
|
/** Execute a bash command in the sandbox and return buffered result. */
|
|
136
129
|
async executeCommand(command, options) {
|
|
@@ -326,3 +319,26 @@ export class Sandbox {
|
|
|
326
319
|
return this.endpoint;
|
|
327
320
|
}
|
|
328
321
|
}
|
|
322
|
+
export async function waitForSandboxReady(input) {
|
|
323
|
+
const maxWaitMs = input.maxWaitMs ?? 60_000;
|
|
324
|
+
const pollIntervalMs = input.pollIntervalMs ?? 2_000;
|
|
325
|
+
const start = Date.now();
|
|
326
|
+
while (Date.now() - start < maxWaitMs) {
|
|
327
|
+
await new Promise((resolve) => dntShim.setTimeout(resolve, pollIntervalMs));
|
|
328
|
+
const res = await fetch(`${input.apiUrl}/sandbox-sessions/${input.id}`, {
|
|
329
|
+
headers: { Authorization: `Bearer ${input.authToken}` },
|
|
330
|
+
});
|
|
331
|
+
if (!res.ok) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const data = await res.json();
|
|
335
|
+
if (data.status === "running")
|
|
336
|
+
return;
|
|
337
|
+
if (data.status === "error" || data.status === "deleting") {
|
|
338
|
+
throw INITIALIZATION_ERROR.create({
|
|
339
|
+
detail: `Sandbox failed to start: status=${data.status}`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
throw TIMEOUT_ERROR.create({ detail: "Sandbox did not become ready within timeout" });
|
|
344
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.276";
|
|
2
2
|
//# sourceMappingURL=version-constant.d.ts.map
|
package/package.json
CHANGED
package/src/deno.js
CHANGED
|
@@ -16,6 +16,16 @@ export interface DurableRunSink<
|
|
|
16
16
|
cancelRun(run: TRun, terminalState: TTerminalState): Promise<void> | void;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Placeholder host-facing server config reserved for the future hosted service
|
|
21
|
+
* implementation.
|
|
22
|
+
*/
|
|
23
|
+
export interface AgentServiceServerConfig {
|
|
24
|
+
port?: number;
|
|
25
|
+
basePath?: string;
|
|
26
|
+
cors?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
/**
|
|
20
30
|
* Phase-0 contract draft for the future framework-owned hosted agent service.
|
|
21
31
|
*/
|
|
@@ -25,7 +35,9 @@ export interface AgentContract<
|
|
|
25
35
|
TEvent = unknown,
|
|
26
36
|
TTerminalState = unknown,
|
|
27
37
|
> {
|
|
38
|
+
serviceName: string;
|
|
28
39
|
agent: Agent;
|
|
40
|
+
server?: AgentServiceServerConfig;
|
|
29
41
|
durableRunSink?: DurableRunSink<TStartInput, TRun, TEvent, TTerminalState>;
|
|
30
42
|
}
|
|
31
43
|
|
package/src/src/agent/index.ts
CHANGED
package/src/src/sandbox/index.ts
CHANGED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import * as dntShim from "../../_dnt.shims.js";
|
|
2
|
+
import { REQUEST_ERROR } from "../errors/index.js";
|
|
3
|
+
import {
|
|
4
|
+
type CommandJob,
|
|
5
|
+
type CommandJobOutput,
|
|
6
|
+
type CommandJobStatus,
|
|
7
|
+
type ExecOptions,
|
|
8
|
+
type ExecResult,
|
|
9
|
+
type ExecStreamEvent,
|
|
10
|
+
resolveSandboxApiUrl,
|
|
11
|
+
resolveSandboxAuthToken,
|
|
12
|
+
type SandboxOptions,
|
|
13
|
+
waitForSandboxReady,
|
|
14
|
+
} from "./sandbox.js";
|
|
15
|
+
|
|
16
|
+
export interface LazySandboxOptions extends SandboxOptions {
|
|
17
|
+
getProjectId?: () => string | null | undefined;
|
|
18
|
+
startupTimeoutMs?: number;
|
|
19
|
+
pollIntervalMs?: number;
|
|
20
|
+
heartbeatIntervalMs?: number;
|
|
21
|
+
heartbeatGraceMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SandboxSessionRecord {
|
|
25
|
+
id: string;
|
|
26
|
+
endpoint: string;
|
|
27
|
+
status: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 180_000;
|
|
31
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
32
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
33
|
+
const DEFAULT_HEARTBEAT_GRACE_MS = 5_000;
|
|
34
|
+
|
|
35
|
+
/** Lazily provisions sandbox sessions and keeps them alive while in use. */
|
|
36
|
+
export class LazySandbox {
|
|
37
|
+
private readonly apiUrl: string;
|
|
38
|
+
private readonly authToken: string;
|
|
39
|
+
private readonly getProjectId: () => string | null | undefined;
|
|
40
|
+
private readonly startupTimeoutMs: number;
|
|
41
|
+
private readonly pollIntervalMs: number;
|
|
42
|
+
private readonly heartbeatIntervalMs: number;
|
|
43
|
+
private readonly heartbeatGraceMs: number;
|
|
44
|
+
|
|
45
|
+
private endpoint: string | null = null;
|
|
46
|
+
private sessionId: string | null = null;
|
|
47
|
+
private sessionProjectId: string | null = null;
|
|
48
|
+
private ensurePromise: Promise<void> | null = null;
|
|
49
|
+
private closePromise: Promise<void> | null = null;
|
|
50
|
+
private heartbeatPromise: Promise<void> | null = null;
|
|
51
|
+
private heartbeatTimer: ReturnType<typeof dntShim.setInterval> | null = null;
|
|
52
|
+
private lastHeartbeatAt = 0;
|
|
53
|
+
|
|
54
|
+
constructor(options: LazySandboxOptions = {}) {
|
|
55
|
+
this.apiUrl = resolveSandboxApiUrl(options);
|
|
56
|
+
this.authToken = resolveSandboxAuthToken(options);
|
|
57
|
+
this.getProjectId = options.getProjectId ?? (() => options.projectId);
|
|
58
|
+
this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
|
|
59
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
60
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
61
|
+
this.heartbeatGraceMs = options.heartbeatGraceMs ?? DEFAULT_HEARTBEAT_GRACE_MS;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async ensure(): Promise<void> {
|
|
65
|
+
if (this.endpoint) return;
|
|
66
|
+
if (this.ensurePromise) {
|
|
67
|
+
await this.ensurePromise;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const promise = this.bootstrapSession();
|
|
72
|
+
this.ensurePromise = promise;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await promise;
|
|
76
|
+
} finally {
|
|
77
|
+
if (this.ensurePromise === promise) {
|
|
78
|
+
this.ensurePromise = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async executeCommand(command: string, options?: ExecOptions): Promise<ExecResult> {
|
|
84
|
+
let stdout = "";
|
|
85
|
+
let stderr = "";
|
|
86
|
+
let exitCode = 1;
|
|
87
|
+
|
|
88
|
+
for await (const event of this.executeStream(command, options)) {
|
|
89
|
+
switch (event.type) {
|
|
90
|
+
case "stdout":
|
|
91
|
+
stdout += event.data ?? "";
|
|
92
|
+
break;
|
|
93
|
+
case "stderr":
|
|
94
|
+
stderr += event.data ?? "";
|
|
95
|
+
break;
|
|
96
|
+
case "exit":
|
|
97
|
+
exitCode = event.exitCode ?? 1;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { stdout, stderr, exitCode };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async *executeStream(command: string, options?: ExecOptions): AsyncGenerator<ExecStreamEvent> {
|
|
106
|
+
await this.touchSession();
|
|
107
|
+
|
|
108
|
+
const res = await fetch(`${this.requireEndpoint()}/exec`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: this.jsonHeaders(),
|
|
111
|
+
body: JSON.stringify({ command, ...options }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
throw REQUEST_ERROR.create({ detail: `Exec failed: ${res.status} ${await res.text()}` });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!res.body) {
|
|
119
|
+
throw new Error("Exec response has no body");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const reader = res.body.getReader();
|
|
123
|
+
const decoder = new TextDecoder();
|
|
124
|
+
let buffer = "";
|
|
125
|
+
|
|
126
|
+
while (true) {
|
|
127
|
+
const { done, value } = await reader.read();
|
|
128
|
+
if (done) break;
|
|
129
|
+
|
|
130
|
+
buffer += decoder.decode(value, { stream: true });
|
|
131
|
+
const lines = buffer.split("\n");
|
|
132
|
+
buffer = lines.pop() ?? "";
|
|
133
|
+
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
if (!line.trim()) continue;
|
|
136
|
+
yield JSON.parse(line) as ExecStreamEvent;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (buffer.trim()) {
|
|
141
|
+
yield JSON.parse(buffer) as ExecStreamEvent;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async readFile(path: string): Promise<string> {
|
|
146
|
+
await this.touchSession();
|
|
147
|
+
|
|
148
|
+
const res = await fetch(`${this.requireEndpoint()}/file?path=${encodeURIComponent(path)}`, {
|
|
149
|
+
headers: this.authHeaders(),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!res.ok) {
|
|
153
|
+
throw REQUEST_ERROR.create({ detail: `Read file failed: ${res.status} ${await res.text()}` });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return await res.text();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async writeFiles(files: Array<{ path: string; content: string }>): Promise<void> {
|
|
160
|
+
await this.touchSession();
|
|
161
|
+
|
|
162
|
+
const res = await fetch(`${this.requireEndpoint()}/files`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: this.jsonHeaders(),
|
|
165
|
+
body: JSON.stringify({ files }),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
throw REQUEST_ERROR.create({
|
|
170
|
+
detail: `Write files failed: ${res.status} ${await res.text()}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async startCommandJob(command: string, options?: ExecOptions): Promise<CommandJob> {
|
|
176
|
+
await this.touchSession();
|
|
177
|
+
|
|
178
|
+
const res = await fetch(`${this.requireEndpoint()}/exec/jobs`, {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: this.jsonHeaders(),
|
|
181
|
+
body: JSON.stringify({ command, ...options }),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
throw REQUEST_ERROR.create({
|
|
186
|
+
detail: `Start command job failed: ${res.status} ${await res.text()}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return mapCommandJob(await res.json());
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async getCommandJob(jobId: string): Promise<CommandJob> {
|
|
194
|
+
await this.ensure();
|
|
195
|
+
|
|
196
|
+
const res = await fetch(`${this.requireEndpoint()}/exec/jobs/${jobId}`, {
|
|
197
|
+
headers: this.authHeaders(),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!res.ok) {
|
|
201
|
+
throw REQUEST_ERROR.create({
|
|
202
|
+
detail: `Get command job failed: ${res.status} ${await res.text()}`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return mapCommandJob(await res.json());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async getCommandJobOutput(jobId: string): Promise<CommandJobOutput> {
|
|
210
|
+
await this.ensure();
|
|
211
|
+
|
|
212
|
+
const res = await fetch(`${this.requireEndpoint()}/exec/jobs/${jobId}/output`, {
|
|
213
|
+
headers: this.authHeaders(),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (!res.ok) {
|
|
217
|
+
throw REQUEST_ERROR.create({
|
|
218
|
+
detail: `Get command job output failed: ${res.status} ${await res.text()}`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const json = await res.json();
|
|
223
|
+
return {
|
|
224
|
+
...mapCommandJob(json),
|
|
225
|
+
stdout: json.stdout,
|
|
226
|
+
stderr: json.stderr,
|
|
227
|
+
stdoutTruncated: json.stdout_truncated,
|
|
228
|
+
stderrTruncated: json.stderr_truncated,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async listCommandJobs(): Promise<CommandJob[]> {
|
|
233
|
+
await this.ensure();
|
|
234
|
+
|
|
235
|
+
const res = await fetch(`${this.requireEndpoint()}/exec/jobs`, {
|
|
236
|
+
headers: this.authHeaders(),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
throw REQUEST_ERROR.create({
|
|
241
|
+
detail: `List command jobs failed: ${res.status} ${await res.text()}`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const json = await res.json();
|
|
246
|
+
const jobs = Array.isArray(json) ? json : (json.jobs ?? []);
|
|
247
|
+
return jobs.map((job: Record<string, unknown>) => mapCommandJob(job));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async cancelCommandJob(jobId: string): Promise<CommandJob> {
|
|
251
|
+
await this.ensure();
|
|
252
|
+
|
|
253
|
+
const res = await fetch(`${this.requireEndpoint()}/exec/jobs/${jobId}/cancel`, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: this.authHeaders(),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
throw REQUEST_ERROR.create({
|
|
260
|
+
detail: `Cancel command job failed: ${res.status} ${await res.text()}`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return mapCommandJob(await res.json());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async heartbeat(force = false): Promise<void> {
|
|
268
|
+
const currentSessionId = this.sessionId;
|
|
269
|
+
if (!currentSessionId) return;
|
|
270
|
+
|
|
271
|
+
if (this.heartbeatPromise) {
|
|
272
|
+
await this.heartbeatPromise;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (
|
|
277
|
+
!force && this.lastHeartbeatAt > 0 &&
|
|
278
|
+
Date.now() - this.lastHeartbeatAt < this.heartbeatGraceMs
|
|
279
|
+
) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const promise = (async () => {
|
|
284
|
+
const res = await fetch(`${this.apiUrl}/sandbox-sessions/${currentSessionId}/heartbeat`, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: this.authHeaders(),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (!res.ok) {
|
|
290
|
+
if (this.sessionId === currentSessionId) {
|
|
291
|
+
await this.deleteSession(currentSessionId);
|
|
292
|
+
this.resetSessionState(currentSessionId);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
throw new Error(`Sandbox heartbeat failed: ${res.status} ${await res.text()}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
this.lastHeartbeatAt = Date.now();
|
|
299
|
+
})();
|
|
300
|
+
|
|
301
|
+
this.heartbeatPromise = promise;
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
await promise;
|
|
305
|
+
} finally {
|
|
306
|
+
if (this.heartbeatPromise === promise) {
|
|
307
|
+
this.heartbeatPromise = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async close(): Promise<void> {
|
|
313
|
+
if (this.closePromise) {
|
|
314
|
+
await this.closePromise;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const promise = (async () => {
|
|
319
|
+
if (this.ensurePromise) {
|
|
320
|
+
try {
|
|
321
|
+
await this.ensurePromise;
|
|
322
|
+
} catch {
|
|
323
|
+
// startup failure already handled by the caller path
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const currentSessionId = this.sessionId;
|
|
328
|
+
if (!currentSessionId) {
|
|
329
|
+
this.resetSessionState();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await this.deleteSession(currentSessionId);
|
|
334
|
+
this.resetSessionState(currentSessionId);
|
|
335
|
+
})();
|
|
336
|
+
|
|
337
|
+
this.closePromise = promise;
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
await promise;
|
|
341
|
+
} finally {
|
|
342
|
+
if (this.closePromise === promise) {
|
|
343
|
+
this.closePromise = null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
get id(): string | null {
|
|
349
|
+
return this.sessionId;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
get url(): string | null {
|
|
353
|
+
return this.endpoint;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
get isActive(): boolean {
|
|
357
|
+
return this.endpoint !== null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async bootstrapSession(): Promise<void> {
|
|
361
|
+
const projectId = this.resolveProjectId();
|
|
362
|
+
const res = await fetch(`${this.apiUrl}/sandbox-sessions`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: this.jsonHeaders(),
|
|
365
|
+
body: JSON.stringify(projectId ? { project_id: projectId } : {}),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (!res.ok) {
|
|
369
|
+
throw REQUEST_ERROR.create({
|
|
370
|
+
detail: `Failed to create sandbox: ${res.status} ${await res.text()}`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const session = await res.json();
|
|
375
|
+
this.sessionId = session.id;
|
|
376
|
+
this.sessionProjectId = projectId;
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
const endpoint = await this.resolveReadyEndpoint(session);
|
|
380
|
+
this.endpoint = endpoint;
|
|
381
|
+
await this.heartbeat(true);
|
|
382
|
+
this.startHeartbeatLoop();
|
|
383
|
+
} catch (error) {
|
|
384
|
+
const currentSessionId = this.sessionId;
|
|
385
|
+
if (currentSessionId) {
|
|
386
|
+
await this.deleteSession(currentSessionId);
|
|
387
|
+
}
|
|
388
|
+
this.resetSessionState(currentSessionId ?? undefined);
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async resolveReadyEndpoint(session: SandboxSessionRecord): Promise<string> {
|
|
394
|
+
if (session.status === "running") {
|
|
395
|
+
return session.endpoint;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
await waitForSandboxReady({
|
|
399
|
+
apiUrl: this.apiUrl,
|
|
400
|
+
id: session.id,
|
|
401
|
+
authToken: this.authToken,
|
|
402
|
+
maxWaitMs: this.startupTimeoutMs,
|
|
403
|
+
pollIntervalMs: this.pollIntervalMs,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const res = await fetch(`${this.apiUrl}/sandbox-sessions/${session.id}`, {
|
|
407
|
+
headers: this.authHeaders(),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
if (!res.ok) {
|
|
411
|
+
throw REQUEST_ERROR.create({
|
|
412
|
+
detail: `Failed to get sandbox: ${res.status} ${await res.text()}`,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const nextSession = await res.json();
|
|
417
|
+
return nextSession.endpoint;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async touchSession(): Promise<void> {
|
|
421
|
+
const projectId = this.resolveProjectId();
|
|
422
|
+
if (this.endpoint && this.sessionProjectId !== projectId) {
|
|
423
|
+
const currentSessionId = this.sessionId;
|
|
424
|
+
if (currentSessionId) {
|
|
425
|
+
await this.deleteSession(currentSessionId);
|
|
426
|
+
}
|
|
427
|
+
this.resetSessionState(currentSessionId ?? undefined);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
await this.ensure();
|
|
431
|
+
await this.heartbeat();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private startHeartbeatLoop(): void {
|
|
435
|
+
if (!this.sessionId || this.heartbeatTimer) return;
|
|
436
|
+
|
|
437
|
+
this.heartbeatTimer = dntShim.setInterval(() => {
|
|
438
|
+
void this.heartbeat().catch(() => {
|
|
439
|
+
// next operation will reprovision
|
|
440
|
+
});
|
|
441
|
+
}, this.heartbeatIntervalMs);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private stopHeartbeatLoop(): void {
|
|
445
|
+
if (!this.heartbeatTimer) return;
|
|
446
|
+
clearInterval(this.heartbeatTimer);
|
|
447
|
+
this.heartbeatTimer = null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private async deleteSession(sessionId: string): Promise<void> {
|
|
451
|
+
await fetch(`${this.apiUrl}/sandbox-sessions/${sessionId}`, {
|
|
452
|
+
method: "DELETE",
|
|
453
|
+
headers: this.authHeaders(),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private requireEndpoint(): string {
|
|
458
|
+
if (!this.endpoint) {
|
|
459
|
+
throw new Error("Sandbox endpoint unavailable");
|
|
460
|
+
}
|
|
461
|
+
return this.endpoint;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private resolveProjectId(): string | null {
|
|
465
|
+
return this.getProjectId() ?? null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private resetSessionState(sessionId?: string): void {
|
|
469
|
+
if (!sessionId || this.sessionId === sessionId) {
|
|
470
|
+
this.stopHeartbeatLoop();
|
|
471
|
+
this.endpoint = null;
|
|
472
|
+
this.sessionId = null;
|
|
473
|
+
this.sessionProjectId = null;
|
|
474
|
+
this.heartbeatPromise = null;
|
|
475
|
+
this.lastHeartbeatAt = 0;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private authHeaders(): HeadersInit {
|
|
480
|
+
return { Authorization: `Bearer ${this.authToken}` };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private jsonHeaders(): HeadersInit {
|
|
484
|
+
return {
|
|
485
|
+
...this.authHeaders(),
|
|
486
|
+
"Content-Type": "application/json",
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function mapCommandJob(json: Record<string, unknown>): CommandJob {
|
|
492
|
+
return {
|
|
493
|
+
id: json.id as string,
|
|
494
|
+
status: json.status as CommandJobStatus,
|
|
495
|
+
exitCode: json.exit_code as number | null,
|
|
496
|
+
signal: json.signal as string | null,
|
|
497
|
+
startedAt: json.started_at as string,
|
|
498
|
+
finishedAt: json.finished_at as string | null,
|
|
499
|
+
heartbeatStatus: json.heartbeat_status as "disabled" | "healthy" | "degraded",
|
|
500
|
+
lastHeartbeatAt: json.last_heartbeat_at as string | null,
|
|
501
|
+
lastHeartbeatError: json.last_heartbeat_error as string | null,
|
|
502
|
+
heartbeatFailureCount: json.heartbeat_failure_count as number,
|
|
503
|
+
};
|
|
504
|
+
}
|