@tyvm/knowhow 0.0.132 → 0.0.135
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/package.json +1 -1
- package/src/agents/base/base.ts +63 -21
- package/src/cli.ts +2 -0
- package/src/commands/hash.ts +15 -7
- package/src/commands/sandbox.ts +540 -0
- package/src/tunnel.ts +11 -0
- package/src/worker.ts +102 -10
- package/tests/agents/interrupt.test.ts +207 -0
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/base/base.d.ts +2 -0
- package/ts_build/src/agents/base/base.js +30 -19
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/cli.js +2 -0
- package/ts_build/src/cli.js.map +1 -1
- package/ts_build/src/commands/hash.js +12 -7
- package/ts_build/src/commands/hash.js.map +1 -1
- package/ts_build/src/commands/sandbox.d.ts +2 -0
- package/ts_build/src/commands/sandbox.js +353 -0
- package/ts_build/src/commands/sandbox.js.map +1 -0
- package/ts_build/src/tunnel.d.ts +1 -0
- package/ts_build/src/tunnel.js +6 -1
- package/ts_build/src/tunnel.js.map +1 -1
- package/ts_build/src/worker.js +56 -6
- package/ts_build/src/worker.js.map +1 -1
- package/ts_build/tests/agents/interrupt.test.d.ts +15 -0
- package/ts_build/tests/agents/interrupt.test.js +146 -0
- package/ts_build/tests/agents/interrupt.test.js.map +1 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { KNOWHOW_API_URL, loadKnowhowJwt } from "../services/KnowhowClient";
|
|
3
|
+
import http from "../utils/http";
|
|
4
|
+
|
|
5
|
+
// ─── API client helper ────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function getBaseUrl(): string {
|
|
8
|
+
return `${process.env.KNOWHOW_API_URL || KNOWHOW_API_URL}/api`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getAuthHeaders(): Record<string, string> {
|
|
12
|
+
const token =
|
|
13
|
+
process.env.KNOWHOW_API_TOKEN ||
|
|
14
|
+
(() => {
|
|
15
|
+
try {
|
|
16
|
+
return loadKnowhowJwt();
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"No API token found. Set KNOWHOW_API_TOKEN env var or run `knowhow login`."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
})();
|
|
23
|
+
return { Authorization: `Bearer ${token}` };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function apiGet<T>(path: string): Promise<T> {
|
|
27
|
+
const res = await http.get<T>(`${getBaseUrl()}${path}`, {
|
|
28
|
+
headers: getAuthHeaders(),
|
|
29
|
+
});
|
|
30
|
+
return res.data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
|
34
|
+
const res = await http.post<T>(`${getBaseUrl()}${path}`, body, {
|
|
35
|
+
headers: getAuthHeaders(),
|
|
36
|
+
});
|
|
37
|
+
return res.data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function apiDelete(path: string): Promise<void> {
|
|
41
|
+
await http.delete(`${getBaseUrl()}${path}`, {
|
|
42
|
+
headers: getAuthHeaders(),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Domain types (minimal shapes we care about) ──────────────────────────────
|
|
47
|
+
|
|
48
|
+
interface Sandbox {
|
|
49
|
+
id: string;
|
|
50
|
+
name?: string | null;
|
|
51
|
+
status: string;
|
|
52
|
+
vmStatus?: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SandboxSnapshot {
|
|
56
|
+
id: string;
|
|
57
|
+
sandboxId: string;
|
|
58
|
+
label?: string | null;
|
|
59
|
+
description?: string | null;
|
|
60
|
+
status: string;
|
|
61
|
+
errorMsg?: string | null;
|
|
62
|
+
statusMessage?: string | null;
|
|
63
|
+
snapshotContent?: string;
|
|
64
|
+
setupScript?: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Resolve helpers ──────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
async function resolveSandboxId(nameOrId: string): Promise<string> {
|
|
70
|
+
// Looks like a cuid/nanoid — use directly
|
|
71
|
+
if (/^[a-z0-9]{15,}$/i.test(nameOrId)) {
|
|
72
|
+
return nameOrId;
|
|
73
|
+
}
|
|
74
|
+
const sandboxes = await apiGet<Sandbox[]>("/sandboxes");
|
|
75
|
+
const match = sandboxes.find(
|
|
76
|
+
(s) => s.name === nameOrId || s.id === nameOrId
|
|
77
|
+
);
|
|
78
|
+
if (!match) {
|
|
79
|
+
const names = sandboxes.map((s) => s.name || s.id).join(", ");
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Sandbox not found: "${nameOrId}". Available: ${names || "(none)"}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return match.id;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resolveSnapshot(
|
|
88
|
+
sandboxId: string,
|
|
89
|
+
nameOrId: string
|
|
90
|
+
): Promise<SandboxSnapshot> {
|
|
91
|
+
// Try direct ID
|
|
92
|
+
if (/^[a-z0-9]{15,}$/i.test(nameOrId)) {
|
|
93
|
+
try {
|
|
94
|
+
return await apiGet<SandboxSnapshot>(
|
|
95
|
+
`/sandboxes/${sandboxId}/snapshots/${nameOrId}`
|
|
96
|
+
);
|
|
97
|
+
} catch {
|
|
98
|
+
// fall through to name search
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Search by label
|
|
102
|
+
const snapshots = await apiGet<SandboxSnapshot[]>(
|
|
103
|
+
`/sandboxes/${sandboxId}/snapshots`
|
|
104
|
+
);
|
|
105
|
+
let match = snapshots.find(
|
|
106
|
+
(s) => s.label === nameOrId || s.id === nameOrId
|
|
107
|
+
);
|
|
108
|
+
if (!match) {
|
|
109
|
+
// Org-wide search
|
|
110
|
+
const orgSnaps = await apiGet<SandboxSnapshot[]>("/org-snapshots");
|
|
111
|
+
match = orgSnaps.find((s) => s.label === nameOrId || s.id === nameOrId);
|
|
112
|
+
}
|
|
113
|
+
if (!match) {
|
|
114
|
+
const labels = snapshots.map((s) => s.label || s.id).join(", ");
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Snapshot not found: "${nameOrId}". Available for sandbox: ${labels || "(none)"}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return match;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Polling helpers ──────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
async function pollSandboxStatus(
|
|
125
|
+
sandboxId: string,
|
|
126
|
+
targetStatuses: string[],
|
|
127
|
+
timeoutMs: number,
|
|
128
|
+
label = sandboxId
|
|
129
|
+
): Promise<Sandbox> {
|
|
130
|
+
const deadline = Date.now() + timeoutMs;
|
|
131
|
+
let lastStatus = "";
|
|
132
|
+
while (Date.now() < deadline) {
|
|
133
|
+
const sandbox = await apiGet<Sandbox>(`/sandboxes/${sandboxId}`);
|
|
134
|
+
if (sandbox.status !== lastStatus) {
|
|
135
|
+
console.log(` [${label}] status: ${sandbox.status}`);
|
|
136
|
+
lastStatus = sandbox.status;
|
|
137
|
+
}
|
|
138
|
+
if (targetStatuses.includes(sandbox.status)) return sandbox;
|
|
139
|
+
if (["error", "destroyed", "terminated"].includes(sandbox.status)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Sandbox ${label} entered terminal status: ${sandbox.status}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
145
|
+
}
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Timeout waiting for sandbox ${label} to reach ${targetStatuses.join("/")} (${timeoutMs}ms). Last: ${lastStatus}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function pollSnapshotStatus(
|
|
152
|
+
sandboxId: string,
|
|
153
|
+
snapshotId: string,
|
|
154
|
+
targetStatuses: string[],
|
|
155
|
+
timeoutMs: number
|
|
156
|
+
): Promise<SandboxSnapshot> {
|
|
157
|
+
const deadline = Date.now() + timeoutMs;
|
|
158
|
+
let lastStatus = "";
|
|
159
|
+
while (Date.now() < deadline) {
|
|
160
|
+
const snap = await apiGet<SandboxSnapshot>(
|
|
161
|
+
`/sandboxes/${sandboxId}/snapshots/${snapshotId}`
|
|
162
|
+
);
|
|
163
|
+
if (snap.status !== lastStatus) {
|
|
164
|
+
console.log(` [snapshot] status: ${snap.status}`);
|
|
165
|
+
if (snap.statusMessage) console.log(` message: ${snap.statusMessage}`);
|
|
166
|
+
lastStatus = snap.status;
|
|
167
|
+
}
|
|
168
|
+
if (targetStatuses.includes(snap.status)) return snap;
|
|
169
|
+
if (["error", "failed"].includes(snap.status)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Snapshot failed: ${snap.errorMsg || snap.statusMessage || "(no details)"}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
175
|
+
}
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Timeout waiting for snapshot to reach ${targetStatuses.join("/")} (${timeoutMs}ms). Last: ${lastStatus}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Command registration ─────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export function addSandboxCommand(program: Command): void {
|
|
184
|
+
const sandboxCmd = program
|
|
185
|
+
.command("sandbox")
|
|
186
|
+
.description("Manage Knowhow sandboxes and snapshots");
|
|
187
|
+
|
|
188
|
+
// ── sandbox list ────────────────────────────────────────────────────────────
|
|
189
|
+
sandboxCmd
|
|
190
|
+
.command("list")
|
|
191
|
+
.description("List all sandboxes in your org")
|
|
192
|
+
.option("--status <status>", "Filter by status (running|stopped|all)", "all")
|
|
193
|
+
.option("--json", "Output raw JSON")
|
|
194
|
+
.action(async (opts: { status: string; json?: boolean }) => {
|
|
195
|
+
const sandboxes = await apiGet<Sandbox[]>("/sandboxes");
|
|
196
|
+
const filtered =
|
|
197
|
+
opts.status === "all"
|
|
198
|
+
? sandboxes
|
|
199
|
+
: sandboxes.filter((s) => s.status === opts.status);
|
|
200
|
+
if (opts.json) {
|
|
201
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
202
|
+
} else {
|
|
203
|
+
console.log(`Found ${filtered.length} sandbox(es):\n`);
|
|
204
|
+
for (const s of filtered) {
|
|
205
|
+
console.log(
|
|
206
|
+
` ${s.id} name=${s.name || "(none)"} status=${s.status} vmStatus=${s.vmStatus || "?"}`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ── sandbox list-snapshots ──────────────────────────────────────────────────
|
|
213
|
+
sandboxCmd
|
|
214
|
+
.command("list-snapshots")
|
|
215
|
+
.description("List snapshots (org-wide or for a specific sandbox)")
|
|
216
|
+
.option("--sandbox-id <id>", "Filter by sandbox ID")
|
|
217
|
+
.option("--sandbox-name <name>", "Filter by sandbox name")
|
|
218
|
+
.option("--json", "Output raw JSON")
|
|
219
|
+
.action(
|
|
220
|
+
async (opts: {
|
|
221
|
+
sandboxId?: string;
|
|
222
|
+
sandboxName?: string;
|
|
223
|
+
json?: boolean;
|
|
224
|
+
}) => {
|
|
225
|
+
let snapshots: SandboxSnapshot[];
|
|
226
|
+
if (opts.sandboxId || opts.sandboxName) {
|
|
227
|
+
const sbId = opts.sandboxId || (await resolveSandboxId(opts.sandboxName!));
|
|
228
|
+
snapshots = await apiGet<SandboxSnapshot[]>(
|
|
229
|
+
`/sandboxes/${sbId}/snapshots`
|
|
230
|
+
);
|
|
231
|
+
} else {
|
|
232
|
+
snapshots = await apiGet<SandboxSnapshot[]>("/org-snapshots");
|
|
233
|
+
}
|
|
234
|
+
if (opts.json) {
|
|
235
|
+
console.log(JSON.stringify(snapshots, null, 2));
|
|
236
|
+
} else {
|
|
237
|
+
console.log(`Found ${snapshots.length} snapshot(s):\n`);
|
|
238
|
+
for (const s of snapshots) {
|
|
239
|
+
console.log(
|
|
240
|
+
` ${s.id} label=${s.label || "(none)"} status=${s.status} sandboxId=${s.sandboxId}`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// ── sandbox exec ────────────────────────────────────────────────────────────
|
|
248
|
+
sandboxCmd
|
|
249
|
+
.command("exec")
|
|
250
|
+
.description("Execute a command in a running sandbox")
|
|
251
|
+
.option("--sandbox-id <id>", "Sandbox ID")
|
|
252
|
+
.option("--sandbox-name <name>", "Sandbox name")
|
|
253
|
+
.requiredOption("--cmd <command>", "Shell command to run (passed to /bin/bash -c)")
|
|
254
|
+
.option("--timeout-ms <ms>", "Timeout in ms (max 300000)", "60000")
|
|
255
|
+
.action(
|
|
256
|
+
async (opts: {
|
|
257
|
+
sandboxId?: string;
|
|
258
|
+
sandboxName?: string;
|
|
259
|
+
cmd: string;
|
|
260
|
+
timeoutMs: string;
|
|
261
|
+
}) => {
|
|
262
|
+
const sbId =
|
|
263
|
+
opts.sandboxId || (opts.sandboxName ? await resolveSandboxId(opts.sandboxName) : null);
|
|
264
|
+
if (!sbId) throw new Error("Provide --sandbox-id or --sandbox-name");
|
|
265
|
+
|
|
266
|
+
console.log(`Executing in sandbox ${sbId}: ${opts.cmd}`);
|
|
267
|
+
const result = await apiPost<{
|
|
268
|
+
stdout?: string;
|
|
269
|
+
stderr?: string;
|
|
270
|
+
exitCode?: number;
|
|
271
|
+
}>(`/sandboxes/${sbId}/exec`, {
|
|
272
|
+
command: "/bin/bash",
|
|
273
|
+
args: ["-c", opts.cmd],
|
|
274
|
+
timeoutMs: Math.min(Number(opts.timeoutMs), 300000),
|
|
275
|
+
});
|
|
276
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
277
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
278
|
+
if (result.exitCode != null && result.exitCode !== 0) {
|
|
279
|
+
process.exit(result.exitCode);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// ── sandbox start ───────────────────────────────────────────────────────────
|
|
285
|
+
sandboxCmd
|
|
286
|
+
.command("start")
|
|
287
|
+
.description("Start a stopped sandbox")
|
|
288
|
+
.option("--sandbox-id <id>", "Sandbox ID")
|
|
289
|
+
.option("--sandbox-name <name>", "Sandbox name")
|
|
290
|
+
.option("--wait", "Wait until sandbox is running")
|
|
291
|
+
.option("--timeout-minutes <min>", "Max wait time in minutes", "10")
|
|
292
|
+
.action(
|
|
293
|
+
async (opts: {
|
|
294
|
+
sandboxId?: string;
|
|
295
|
+
sandboxName?: string;
|
|
296
|
+
wait?: boolean;
|
|
297
|
+
timeoutMinutes: string;
|
|
298
|
+
}) => {
|
|
299
|
+
const sbId =
|
|
300
|
+
opts.sandboxId || (opts.sandboxName ? await resolveSandboxId(opts.sandboxName) : null);
|
|
301
|
+
if (!sbId) throw new Error("Provide --sandbox-id or --sandbox-name");
|
|
302
|
+
console.log(`Starting sandbox ${sbId}...`);
|
|
303
|
+
await apiPost(`/sandboxes/${sbId}/start`);
|
|
304
|
+
if (opts.wait) {
|
|
305
|
+
await pollSandboxStatus(sbId, ["running"], Number(opts.timeoutMinutes) * 60000);
|
|
306
|
+
console.log("✓ Sandbox is running");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// ── sandbox stop ────────────────────────────────────────────────────────────
|
|
312
|
+
sandboxCmd
|
|
313
|
+
.command("stop")
|
|
314
|
+
.description("Stop a running sandbox")
|
|
315
|
+
.option("--sandbox-id <id>", "Sandbox ID")
|
|
316
|
+
.option("--sandbox-name <name>", "Sandbox name")
|
|
317
|
+
.option("--wait", "Wait until sandbox is stopped")
|
|
318
|
+
.option("--timeout-minutes <min>", "Max wait time in minutes", "10")
|
|
319
|
+
.action(
|
|
320
|
+
async (opts: {
|
|
321
|
+
sandboxId?: string;
|
|
322
|
+
sandboxName?: string;
|
|
323
|
+
wait?: boolean;
|
|
324
|
+
timeoutMinutes: string;
|
|
325
|
+
}) => {
|
|
326
|
+
const sbId =
|
|
327
|
+
opts.sandboxId || (opts.sandboxName ? await resolveSandboxId(opts.sandboxName) : null);
|
|
328
|
+
if (!sbId) throw new Error("Provide --sandbox-id or --sandbox-name");
|
|
329
|
+
console.log(`Stopping sandbox ${sbId}...`);
|
|
330
|
+
await apiPost(`/sandboxes/${sbId}/stop`);
|
|
331
|
+
if (opts.wait) {
|
|
332
|
+
await pollSandboxStatus(sbId, ["stopped"], Number(opts.timeoutMinutes) * 60000);
|
|
333
|
+
console.log("✓ Sandbox is stopped");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// ── sandbox delete-snapshot ─────────────────────────────────────────────────
|
|
339
|
+
sandboxCmd
|
|
340
|
+
.command("delete-snapshot")
|
|
341
|
+
.description("Delete a snapshot by ID")
|
|
342
|
+
.option("--sandbox-id <id>", "Sandbox ID")
|
|
343
|
+
.option("--sandbox-name <name>", "Sandbox name")
|
|
344
|
+
.requiredOption("--snapshot-id <id>", "Snapshot ID to delete")
|
|
345
|
+
.action(
|
|
346
|
+
async (opts: {
|
|
347
|
+
sandboxId?: string;
|
|
348
|
+
sandboxName?: string;
|
|
349
|
+
snapshotId: string;
|
|
350
|
+
}) => {
|
|
351
|
+
const sbId =
|
|
352
|
+
opts.sandboxId || (opts.sandboxName ? await resolveSandboxId(opts.sandboxName) : null);
|
|
353
|
+
if (!sbId) throw new Error("Provide --sandbox-id or --sandbox-name");
|
|
354
|
+
await apiDelete(`/sandboxes/${sbId}/snapshots/${opts.snapshotId}`);
|
|
355
|
+
console.log(`✓ Deleted snapshot ${opts.snapshotId}`);
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// ── sandbox regenerate-snapshot ─────────────────────────────────────────────
|
|
360
|
+
sandboxCmd
|
|
361
|
+
.command("regenerate-snapshot")
|
|
362
|
+
.description(
|
|
363
|
+
"Regenerate a snapshot: forks an ephemeral sandbox from it, runs the\n" +
|
|
364
|
+
"snapshot's setupScript on a fresh base image, captures a new snapshot\n" +
|
|
365
|
+
"with the same label, then destroys the ephemeral sandbox.\n\n" +
|
|
366
|
+
"Typical CI usage (run before tests, only when inputs changed):\n" +
|
|
367
|
+
" knowhow hash --name prisma-gen --input 'prisma/schema/**,package-lock.json' \\\\\n" +
|
|
368
|
+
" || knowhow sandbox regenerate-snapshot \\\\\n" +
|
|
369
|
+
" --sandbox-name knowhow-web --snapshot-name tests-ready"
|
|
370
|
+
)
|
|
371
|
+
.option("--sandbox-id <id>", "Source sandbox ID")
|
|
372
|
+
.option("--sandbox-name <name>", "Source sandbox name (alternative to --sandbox-id)")
|
|
373
|
+
.option("--snapshot-id <id>", "Snapshot ID to regenerate")
|
|
374
|
+
.option("--snapshot-name <name>", "Snapshot label to regenerate")
|
|
375
|
+
.option("--no-wait", "Return immediately without waiting for snapshot to be ready")
|
|
376
|
+
.option("--timeout-minutes <min>", "Max time for the full regen cycle", "60")
|
|
377
|
+
.action(
|
|
378
|
+
async (opts: {
|
|
379
|
+
sandboxId?: string;
|
|
380
|
+
sandboxName?: string;
|
|
381
|
+
snapshotId?: string;
|
|
382
|
+
snapshotName?: string;
|
|
383
|
+
wait: boolean; // commander flips --no-wait → wait=false
|
|
384
|
+
timeoutMinutes: string;
|
|
385
|
+
}) => {
|
|
386
|
+
const timeoutMs = Number(opts.timeoutMinutes) * 60 * 1000;
|
|
387
|
+
const deadline = Date.now() + timeoutMs;
|
|
388
|
+
|
|
389
|
+
// ── 1. Resolve sandbox ──────────────────────────────────────────────
|
|
390
|
+
if (!opts.sandboxId && !opts.sandboxName) {
|
|
391
|
+
throw new Error("Provide --sandbox-id or --sandbox-name");
|
|
392
|
+
}
|
|
393
|
+
const sandboxId =
|
|
394
|
+
opts.sandboxId || (await resolveSandboxId(opts.sandboxName!));
|
|
395
|
+
|
|
396
|
+
// ── 2. Resolve snapshot ─────────────────────────────────────────────
|
|
397
|
+
if (!opts.snapshotId && !opts.snapshotName) {
|
|
398
|
+
throw new Error("Provide --snapshot-id or --snapshot-name");
|
|
399
|
+
}
|
|
400
|
+
const snapshot = opts.snapshotId
|
|
401
|
+
? await apiGet<SandboxSnapshot>(
|
|
402
|
+
`/sandboxes/${sandboxId}/snapshots/${opts.snapshotId}`
|
|
403
|
+
)
|
|
404
|
+
: await resolveSnapshot(sandboxId, opts.snapshotName!);
|
|
405
|
+
const snapshotId = snapshot.id;
|
|
406
|
+
|
|
407
|
+
console.log(
|
|
408
|
+
`\n🔄 Regenerating snapshot "${snapshot.label || snapshotId}" (${snapshotId})`
|
|
409
|
+
);
|
|
410
|
+
console.log(` Source sandbox: ${sandboxId}`);
|
|
411
|
+
|
|
412
|
+
if (snapshot.status !== "ready") {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`Snapshot must be in 'ready' status to regenerate (current: ${snapshot.status})`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (!snapshot.setupScript) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
"Snapshot has no setupScript. Add one via the UI before regenerating."
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── 3. Fork ephemeral sandbox from the snapshot ─────────────────────
|
|
424
|
+
console.log("\n📦 Step 1/4: Forking ephemeral sandbox from snapshot...");
|
|
425
|
+
const ephemeralName = `regen-${snapshotId.slice(0, 8)}-${Date.now()}`;
|
|
426
|
+
const forkResult = await apiPost<{ id?: string; sandboxId?: string }>(
|
|
427
|
+
"/sandboxes/fork",
|
|
428
|
+
{ snapshotId, name: ephemeralName }
|
|
429
|
+
);
|
|
430
|
+
const ephemeralId = forkResult.id || forkResult.sandboxId;
|
|
431
|
+
if (!ephemeralId) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`Fork did not return a sandbox ID: ${JSON.stringify(forkResult)}`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
console.log(` Ephemeral sandbox: ${ephemeralId} (${ephemeralName})`);
|
|
437
|
+
|
|
438
|
+
// ── 4. Wait for ephemeral sandbox to be running ─────────────────────
|
|
439
|
+
console.log("\n⏳ Step 2/4: Waiting for sandbox to start...");
|
|
440
|
+
await pollSandboxStatus(
|
|
441
|
+
ephemeralId,
|
|
442
|
+
["running"],
|
|
443
|
+
Math.min(deadline - Date.now(), 10 * 60 * 1000),
|
|
444
|
+
ephemeralName
|
|
445
|
+
);
|
|
446
|
+
console.log(" ✓ Running");
|
|
447
|
+
|
|
448
|
+
// ── 5. Execute the setupScript ──────────────────────────────────────
|
|
449
|
+
console.log("\n🔧 Step 3/4: Executing setupScript...");
|
|
450
|
+
console.log(` Script length: ${snapshot.setupScript.length} chars`);
|
|
451
|
+
let execResult: { stdout?: string; stderr?: string; exitCode?: number };
|
|
452
|
+
try {
|
|
453
|
+
execResult = await apiPost<{
|
|
454
|
+
stdout?: string;
|
|
455
|
+
stderr?: string;
|
|
456
|
+
exitCode?: number;
|
|
457
|
+
}>(`/sandboxes/${ephemeralId}/exec`, {
|
|
458
|
+
command: "/bin/bash",
|
|
459
|
+
args: ["-c", snapshot.setupScript],
|
|
460
|
+
timeoutMs: Math.min(deadline - Date.now(), 25 * 60 * 1000),
|
|
461
|
+
});
|
|
462
|
+
} catch (err: any) {
|
|
463
|
+
console.error(`\n❌ setupScript exec failed: ${err.message}`);
|
|
464
|
+
await safeDestroy(ephemeralId);
|
|
465
|
+
throw err;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (execResult.stdout) {
|
|
469
|
+
console.log("--- stdout ---");
|
|
470
|
+
process.stdout.write(execResult.stdout);
|
|
471
|
+
}
|
|
472
|
+
if (execResult.stderr) {
|
|
473
|
+
console.error("--- stderr ---");
|
|
474
|
+
process.stderr.write(execResult.stderr);
|
|
475
|
+
}
|
|
476
|
+
if (execResult.exitCode != null && execResult.exitCode !== 0) {
|
|
477
|
+
await safeDestroy(ephemeralId);
|
|
478
|
+
throw new Error(`setupScript exited with code ${execResult.exitCode}`);
|
|
479
|
+
}
|
|
480
|
+
console.log(" ✓ setupScript completed");
|
|
481
|
+
|
|
482
|
+
// ── 6. Capture new snapshot with the same label ─────────────────────
|
|
483
|
+
console.log("\n📸 Step 4/4: Capturing new snapshot...");
|
|
484
|
+
const newLabel = snapshot.label || `regen-${snapshotId.slice(0, 8)}`;
|
|
485
|
+
let createResult: { snapshot?: SandboxSnapshot; id?: string };
|
|
486
|
+
try {
|
|
487
|
+
createResult = await apiPost<{ snapshot?: SandboxSnapshot; id?: string }>(
|
|
488
|
+
`/sandboxes/${ephemeralId}/snapshots`,
|
|
489
|
+
{
|
|
490
|
+
label: newLabel,
|
|
491
|
+
description: snapshot.description,
|
|
492
|
+
snapshotContent: snapshot.snapshotContent || "full",
|
|
493
|
+
snapshotType: "full",
|
|
494
|
+
}
|
|
495
|
+
);
|
|
496
|
+
} catch (err: any) {
|
|
497
|
+
console.error(`\n❌ Snapshot creation failed: ${err.message}`);
|
|
498
|
+
await safeDestroy(ephemeralId);
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const newSnapshot = createResult.snapshot || (createResult as SandboxSnapshot);
|
|
503
|
+
const newSnapshotId = newSnapshot.id;
|
|
504
|
+
console.log(` New snapshot ID: ${newSnapshotId}`);
|
|
505
|
+
|
|
506
|
+
if (opts.wait) {
|
|
507
|
+
console.log(" Waiting for snapshot upload...");
|
|
508
|
+
await pollSnapshotStatus(
|
|
509
|
+
ephemeralId,
|
|
510
|
+
newSnapshotId,
|
|
511
|
+
["ready"],
|
|
512
|
+
Math.min(deadline - Date.now(), 20 * 60 * 1000)
|
|
513
|
+
);
|
|
514
|
+
console.log(" ✓ Snapshot ready");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ── 7. Destroy ephemeral sandbox ────────────────────────────────────
|
|
518
|
+
console.log("\n🗑 Destroying ephemeral sandbox...");
|
|
519
|
+
await safeDestroy(ephemeralId);
|
|
520
|
+
console.log(" ✓ Destroyed");
|
|
521
|
+
|
|
522
|
+
console.log(
|
|
523
|
+
`\n✅ Snapshot regeneration complete!\n` +
|
|
524
|
+
` Old snapshot ID : ${snapshotId}\n` +
|
|
525
|
+
` New snapshot ID : ${newSnapshotId}\n` +
|
|
526
|
+
` Label : ${newLabel}\n\n` +
|
|
527
|
+
`ℹ The old snapshot still exists. Clean it up with:\n` +
|
|
528
|
+
` knowhow sandbox delete-snapshot --sandbox-id ${sandboxId} --snapshot-id ${snapshotId}`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function safeDestroy(sandboxId: string): Promise<void> {
|
|
535
|
+
try {
|
|
536
|
+
await apiDelete(`/sandboxes/${sandboxId}`);
|
|
537
|
+
} catch (err: any) {
|
|
538
|
+
console.warn(`⚠ Could not destroy sandbox ${sandboxId}: ${err.message}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
package/src/tunnel.ts
CHANGED
|
@@ -114,6 +114,8 @@ export interface TunnelWebSocketOptions {
|
|
|
114
114
|
onClose?: (code: number, reason: string) => void;
|
|
115
115
|
/** Called on error */
|
|
116
116
|
onError?: (error: Error) => void;
|
|
117
|
+
/** Called when a pong frame is received (liveness heartbeat) */
|
|
118
|
+
onPong?: () => void;
|
|
117
119
|
/** Optional passkey auth service — if provided, applies WS middleware to gate tunnel traffic */
|
|
118
120
|
authService?: WorkerPasskeyAuthService | null;
|
|
119
121
|
}
|
|
@@ -138,6 +140,7 @@ export function connectTunnelWebSocket(
|
|
|
138
140
|
onOpen,
|
|
139
141
|
onClose,
|
|
140
142
|
onError,
|
|
143
|
+
onPong,
|
|
141
144
|
authService,
|
|
142
145
|
} = options;
|
|
143
146
|
|
|
@@ -198,6 +201,14 @@ export function connectTunnelWebSocket(
|
|
|
198
201
|
onError?.(error);
|
|
199
202
|
});
|
|
200
203
|
|
|
204
|
+
// Pong watchdog: proves the tunnel socket is genuinely alive (not a
|
|
205
|
+
// snapshot-resume zombie). The caller sends ws.ping() and requires a pong.
|
|
206
|
+
if (onPong) {
|
|
207
|
+
tunnelConnection.on("pong", () => {
|
|
208
|
+
onPong();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
201
212
|
return tunnelConnection;
|
|
202
213
|
}
|
|
203
214
|
|