@opengeni/runtime 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-2PO56VAL.js +3478 -0
- package/dist/chunk-2PO56VAL.js.map +1 -0
- package/dist/index.d.ts +912 -0
- package/dist/index.js +3663 -0
- package/dist/index.js.map +1 -0
- package/dist/sandbox/index.d.ts +1738 -0
- package/dist/sandbox/index.js +187 -0
- package/dist/sandbox/index.js.map +1 -0
- package/package.json +49 -0
- package/src/bundled_hashicorp_terraform_skills/LICENSE +373 -0
- package/src/bundled_hashicorp_terraform_skills/README.md +18 -0
- package/src/bundled_hashicorp_terraform_skills/UPSTREAM_GIT_SHA +1 -0
- package/src/bundled_hashicorp_terraform_skills/azure-verified-modules/SKILL.md +613 -0
- package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +43 -0
- package/src/bundled_hashicorp_terraform_skills/refactor-module/SKILL.md +538 -0
- package/src/bundled_hashicorp_terraform_skills/social-media-marketing/SKILL.md +35 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/SKILL.md +372 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/references/MANUAL-IMPORT.md +113 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-search-import/scripts/list_resources.sh +38 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/SKILL.md +480 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/api-monitoring.md +543 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/component-blocks.md +476 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/deployment-blocks.md +391 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/examples.md +1529 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/linked-stacks.md +187 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/troubleshooting.md +671 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-style-guide/SKILL.md +353 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/SKILL.md +451 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/CI_CD.md +80 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/EXAMPLES.md +314 -0
- package/src/bundled_hashicorp_terraform_skills/terraform-test/references/MOCK_PROVIDERS.md +171 -0
- package/src/codex-tool-search.ts +267 -0
- package/src/context-compaction.ts +538 -0
- package/src/history-sanitizer.ts +719 -0
- package/src/index.ts +3299 -0
- package/src/sandbox/capabilities.ts +69 -0
- package/src/sandbox/channel-a.ts +1031 -0
- package/src/sandbox/display-stack.ts +231 -0
- package/src/sandbox/errors.ts +34 -0
- package/src/sandbox/index.ts +832 -0
- package/src/sandbox/providers/blaxel.ts +35 -0
- package/src/sandbox/providers/cloudflare.ts +24 -0
- package/src/sandbox/providers/daytona.ts +34 -0
- package/src/sandbox/providers/docker.ts +17 -0
- package/src/sandbox/providers/e2b.ts +36 -0
- package/src/sandbox/providers/index.ts +107 -0
- package/src/sandbox/providers/local.ts +13 -0
- package/src/sandbox/providers/modal.ts +55 -0
- package/src/sandbox/providers/none.ts +13 -0
- package/src/sandbox/providers/runloop.ts +32 -0
- package/src/sandbox/providers/selfhosted.ts +96 -0
- package/src/sandbox/providers/types.ts +38 -0
- package/src/sandbox/providers/vercel.ts +29 -0
- package/src/sandbox/recording.ts +286 -0
- package/src/sandbox/routing/backend-resolver.ts +189 -0
- package/src/sandbox/routing/routing-session.ts +455 -0
- package/src/sandbox/select.ts +371 -0
- package/src/sandbox/selfhosted/capabilities.ts +255 -0
- package/src/sandbox/selfhosted/control-rpc.ts +351 -0
- package/src/sandbox/selfhosted/session.ts +930 -0
- package/src/sandbox/selfhosted/testing.ts +230 -0
- package/src/sandbox/stream-port.ts +185 -0
- package/src/sandbox/stream-token.ts +90 -0
- package/src/sandbox/terminal-server.ts +203 -0
- package/src/sandbox-computer.ts +835 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// @opengeni/runtime/sandbox — the recording loop (P4.3).
|
|
2
|
+
//
|
|
3
|
+
// ffmpeg x11grab on :0 → mp4/webm artifact on the box → read bytes → PUT to
|
|
4
|
+
// object storage → recording.available. The "agent films itself proving the fix"
|
|
5
|
+
// loop: ffmpeg reads exactly the :0 framebuffer the agent's computer-use draws to
|
|
6
|
+
// and the human watches over Channel B (zero projection).
|
|
7
|
+
//
|
|
8
|
+
// These are PLAIN functions over a live, externally-owned session handle — NO
|
|
9
|
+
// Temporal, NO worker RPC, NO actor. They live in the agent-loop-free leaf so the
|
|
10
|
+
// SAME process that already holds the resumed-by-id box (the agent turn's own
|
|
11
|
+
// activity for an on-turn recording, or the API in-process for an off-turn/manual
|
|
12
|
+
// finalize) reads the bytes and PUTs them straight to storage. The bytes go
|
|
13
|
+
// box → process memory → storage PUT and are NEVER serialized as a Temporal
|
|
14
|
+
// activity result — the 256 MB-vs-payload-limit concern dissolves (F10).
|
|
15
|
+
//
|
|
16
|
+
// ── Adversarial-review fixes folded in (module 05 §Adversarial) ──────────────
|
|
17
|
+
// F1 exec is OPTIONAL on Modal (only execCommand) — every command dual-paths.
|
|
18
|
+
// F3 exec/execCommand YIELDS — the SIGINT-and-wait loop is bounded well under
|
|
19
|
+
// the yield window; a direct `base64` exec does the byte transfer.
|
|
20
|
+
// F8 the byte read does NOT assume any over-limit behavior — we cap the ffmpeg
|
|
21
|
+
// file by size on the box first (stat) and fail `max-bytes-exceeded` rather
|
|
22
|
+
// than silently uploading a truncated video.
|
|
23
|
+
// FR the byte transfer is a DIRECT exec (`base64 <abs-path>`), NOT readFile:
|
|
24
|
+
// the recording lives at an absolute /tmp path (never the user's workspace
|
|
25
|
+
// /git tree), and readFile rejects paths outside the manifest workspace
|
|
26
|
+
// root ("escapes the workspace root"). The base64 exec passes
|
|
27
|
+
// maxOutputTokens:null so a large recording is never truncated.
|
|
28
|
+
// F9 the box file is deleted ONLY after the storage PUT confirms — never
|
|
29
|
+
// before (so a failed upload leaves the bytes recoverable for a retry).
|
|
30
|
+
// F12 ffmpeg/x11vnc backgrounding does not block the yield (nohup … & echo $!).
|
|
31
|
+
// F14 duration is computed from wall-clock (stop − start), not assumed.
|
|
32
|
+
|
|
33
|
+
import { DESKTOP_STREAM_PORT } from "@opengeni/contracts";
|
|
34
|
+
|
|
35
|
+
export { DESKTOP_STREAM_PORT };
|
|
36
|
+
|
|
37
|
+
export type RecordingCodec = "h264-mp4" | "vp9-webm";
|
|
38
|
+
export type RecordingContentType = "video/mp4" | "video/webm";
|
|
39
|
+
|
|
40
|
+
const DEFAULT_MAX_SECONDS = 600; // 10 min hard ceiling (the -t bound)
|
|
41
|
+
const DEFAULT_FRAMERATE = 15;
|
|
42
|
+
const DEFAULT_MAX_BYTES = 268_435_456; // 256 MB
|
|
43
|
+
const DEFAULT_DIMENSIONS: [number, number] = [1280, 800];
|
|
44
|
+
// The SIGINT-and-wait loop is bounded well under the command yield window (F3).
|
|
45
|
+
const STOP_YIELD_MS = 20_000;
|
|
46
|
+
const EXEC_YIELD_MS = 15_000;
|
|
47
|
+
|
|
48
|
+
export function contentTypeForCodec(codec: RecordingCodec): RecordingContentType {
|
|
49
|
+
return codec === "vp9-webm" ? "video/webm" : "video/mp4";
|
|
50
|
+
}
|
|
51
|
+
export function extForCodec(codec: RecordingCodec): string {
|
|
52
|
+
return codec === "vp9-webm" ? "webm" : "mp4";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** No exec/execCommand on the session — the box cannot run ffmpeg. */
|
|
56
|
+
export class RecordingUnavailableError extends Error {
|
|
57
|
+
constructor(message: string) { super(message); this.name = "RecordingUnavailableError"; }
|
|
58
|
+
}
|
|
59
|
+
/** ffmpeg failed, the file is missing, or the byte read failed. */
|
|
60
|
+
export class RecordingError extends Error {
|
|
61
|
+
constructor(message: string, readonly reason: "ffmpeg-error" | "box-death" | "max-bytes-exceeded" | "display-unavailable") {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "RecordingError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The structural slice of a provider session the recording loop drives. exec and
|
|
68
|
+
// execCommand are optional (Modal has only execCommand — F1); readFile present on
|
|
69
|
+
// every desktop-capable provider.
|
|
70
|
+
type ExecResultLike = { output?: string; stdout?: string; stderr?: string; exitCode?: number | null; sessionId?: number };
|
|
71
|
+
type RecordingSession = {
|
|
72
|
+
exec?: (args: { cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }) => Promise<ExecResultLike>;
|
|
73
|
+
execCommand?: (args: { cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }) => Promise<string>;
|
|
74
|
+
readFile?: (args: { path: string; runAs?: string; maxBytes?: number }) => Promise<string | Uint8Array>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function shq(s: string): string {
|
|
78
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resultOutput(result: ExecResultLike | string): string {
|
|
82
|
+
if (typeof result === "string") return result;
|
|
83
|
+
return [result.output, result.stderr, result.stdout].filter((v): v is string => typeof v === "string" && v.length > 0).join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Default per-command output cap (tokens). The byte-read path overrides this to
|
|
87
|
+
// `null` (no truncation) so a base64-encoded recording is never clipped.
|
|
88
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 4_000;
|
|
89
|
+
|
|
90
|
+
async function run(
|
|
91
|
+
session: RecordingSession,
|
|
92
|
+
cmd: string,
|
|
93
|
+
runAs?: string,
|
|
94
|
+
yieldTimeMs = EXEC_YIELD_MS,
|
|
95
|
+
maxOutputTokens: number | null = DEFAULT_MAX_OUTPUT_TOKENS,
|
|
96
|
+
): Promise<string> {
|
|
97
|
+
// `maxOutputTokens: null` disables the provider's output truncation entirely
|
|
98
|
+
// (SDK truncateOutput returns the raw text when the cap is nullish).
|
|
99
|
+
const args = { cmd, ...(runAs ? { runAs } : {}), yieldTimeMs, maxOutputTokens } as {
|
|
100
|
+
cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number;
|
|
101
|
+
};
|
|
102
|
+
if (typeof session.exec === "function") {
|
|
103
|
+
return resultOutput(await session.exec(args));
|
|
104
|
+
}
|
|
105
|
+
if (typeof session.execCommand === "function") {
|
|
106
|
+
return resultOutput(await session.execCommand(args));
|
|
107
|
+
}
|
|
108
|
+
throw new RecordingUnavailableError("session cannot run commands (no exec/execCommand) — recording unavailable");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Extract the command body from a provider exec banner. Modal's execCommand
|
|
112
|
+
// returns "<chunk banner>\nProcess exited…\nOutput:\n<body>"; the body is what
|
|
113
|
+
// follows the last "Output:\n" marker. Plain exec results (no banner) pass
|
|
114
|
+
// through unchanged.
|
|
115
|
+
function stripExecBanner(raw: string): string {
|
|
116
|
+
const marker = raw.lastIndexOf("\nOutput:\n");
|
|
117
|
+
if (marker >= 0) return raw.slice(marker + "\nOutput:\n".length);
|
|
118
|
+
if (raw.startsWith("Output:\n")) return raw.slice("Output:\n".length);
|
|
119
|
+
return raw;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type StartRecordingInput = {
|
|
123
|
+
recordingId: string;
|
|
124
|
+
codec?: RecordingCodec;
|
|
125
|
+
framerate?: number;
|
|
126
|
+
maxSeconds?: number;
|
|
127
|
+
dimensions?: [number, number];
|
|
128
|
+
display?: string; // ":0"
|
|
129
|
+
runAs?: string;
|
|
130
|
+
tmpDir?: string; // "/tmp"
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type RecordingProcess = {
|
|
134
|
+
recordingId: string;
|
|
135
|
+
codec: RecordingCodec;
|
|
136
|
+
boxPath: string;
|
|
137
|
+
pidFile: string;
|
|
138
|
+
dimensions: [number, number];
|
|
139
|
+
framerate: number;
|
|
140
|
+
/** epoch-ms when ffmpeg was launched (for duration computation, F14). */
|
|
141
|
+
startedAt: number;
|
|
142
|
+
display: string;
|
|
143
|
+
runAs?: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Launch ffmpeg x11grab on :0 → an mp4/webm file on the box. Backgrounded with
|
|
148
|
+
* `nohup … & echo $!` so the launch returns immediately (F12 — the exec does not
|
|
149
|
+
* block on the recording). A hard `-t <maxSeconds>` ceiling bounds a runaway file
|
|
150
|
+
* across a multi-day turn. Returns the handle the caller carries to stop+finalize.
|
|
151
|
+
*/
|
|
152
|
+
export async function startRecording(session: unknown, input: StartRecordingInput): Promise<RecordingProcess> {
|
|
153
|
+
const s = session as RecordingSession;
|
|
154
|
+
const codec = input.codec ?? "h264-mp4";
|
|
155
|
+
const dimensions = input.dimensions ?? DEFAULT_DIMENSIONS;
|
|
156
|
+
const framerate = input.framerate ?? DEFAULT_FRAMERATE;
|
|
157
|
+
const maxSeconds = input.maxSeconds ?? DEFAULT_MAX_SECONDS;
|
|
158
|
+
const display = input.display ?? ":0";
|
|
159
|
+
const tmp = input.tmpDir ?? "/tmp";
|
|
160
|
+
const ext = extForCodec(codec);
|
|
161
|
+
const boxPath = `${tmp}/og-rec-${input.recordingId}.${ext}`;
|
|
162
|
+
const pidFile = `${tmp}/og-rec-${input.recordingId}.pid`;
|
|
163
|
+
const logFile = `${tmp}/og-rec-${input.recordingId}.log`;
|
|
164
|
+
const [w, h] = dimensions;
|
|
165
|
+
const enc = codec === "vp9-webm"
|
|
166
|
+
? `-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1`
|
|
167
|
+
: `-c:v libx264 -preset veryfast -pix_fmt yuv420p -movflags +faststart`;
|
|
168
|
+
const ffmpeg =
|
|
169
|
+
`nohup ffmpeg -hide_banner -loglevel error -f x11grab -draw_mouse 1 -framerate ${framerate} ` +
|
|
170
|
+
`-video_size ${w}x${h} -i ${display}.0 -t ${maxSeconds} ${enc} ${boxPath} ` +
|
|
171
|
+
`</dev/null >${logFile} 2>&1 & echo $! > ${pidFile}`;
|
|
172
|
+
await run(s, `bash -lc ${shq(ffmpeg)}`, input.runAs);
|
|
173
|
+
return {
|
|
174
|
+
recordingId: input.recordingId,
|
|
175
|
+
codec,
|
|
176
|
+
boxPath,
|
|
177
|
+
pidFile,
|
|
178
|
+
dimensions,
|
|
179
|
+
framerate,
|
|
180
|
+
startedAt: Date.now(),
|
|
181
|
+
display,
|
|
182
|
+
...(input.runAs ? { runAs: input.runAs } : {}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* SIGINT ffmpeg (so it writes a clean moov atom / webm trailer) and wait for the
|
|
188
|
+
* pid to exit. Bounded well under the yield window (F3). Idempotent: a missing
|
|
189
|
+
* pid file is a no-op.
|
|
190
|
+
*/
|
|
191
|
+
export async function stopRecording(session: unknown, proc: RecordingProcess): Promise<void> {
|
|
192
|
+
const s = session as RecordingSession;
|
|
193
|
+
const wait = `kill -INT "$(cat ${proc.pidFile})" 2>/dev/null; for i in $(seq 1 80); do kill -0 "$(cat ${proc.pidFile})" 2>/dev/null || break; sleep 0.1; done`;
|
|
194
|
+
await run(s, `bash -lc ${shq(wait)}`, proc.runAs, STOP_YIELD_MS).catch(() => undefined);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type FinalizeRecordingResult = {
|
|
198
|
+
bytes: Uint8Array;
|
|
199
|
+
contentType: RecordingContentType;
|
|
200
|
+
sizeBytes: number;
|
|
201
|
+
durationSeconds: number;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read the finalized recording bytes off the box.
|
|
206
|
+
*
|
|
207
|
+
* TRANSPORT: the bytes are read via a DIRECT exec (`base64 <path>` over stdout),
|
|
208
|
+
* NOT via session.readFile(). The recording artifact lives at an absolute /tmp
|
|
209
|
+
* path on purpose — recordings must never be written inside the user's workspace
|
|
210
|
+
* /git tree — but session.readFile() resolves every path against the manifest
|
|
211
|
+
* workspace root and rejects anything outside it ("Sandbox path … escapes the
|
|
212
|
+
* workspace root"), which fataled finalize. Raw exec runs unrestricted shell, so
|
|
213
|
+
* `base64` reads the /tmp file directly; we decode the base64 back to bytes here.
|
|
214
|
+
* The byte-read exec passes `maxOutputTokens: null` so the provider never
|
|
215
|
+
* truncates a large recording's base64.
|
|
216
|
+
*
|
|
217
|
+
* F8: we DO NOT assume any over-limit behavior. First `stat` the file size on the
|
|
218
|
+
* box; if it exceeds maxBytes, fail `max-bytes-exceeded` (never upload a truncated
|
|
219
|
+
* video). Otherwise read the raw bytes.
|
|
220
|
+
*
|
|
221
|
+
* F9: this does NOT delete the box file. The caller deletes it (deleteRecordingArtifacts)
|
|
222
|
+
* ONLY after the storage PUT + `available` commit — so a failed upload leaves the
|
|
223
|
+
* bytes recoverable on the box for a retry.
|
|
224
|
+
*
|
|
225
|
+
* F14: duration is wall-clock (now − startedAt), a close approximation of the
|
|
226
|
+
* SIGINT-flushed video length.
|
|
227
|
+
*/
|
|
228
|
+
export async function readRecordingBytes(session: unknown, proc: RecordingProcess, maxBytes = DEFAULT_MAX_BYTES): Promise<FinalizeRecordingResult> {
|
|
229
|
+
const s = session as RecordingSession;
|
|
230
|
+
if (typeof s.exec !== "function" && typeof s.execCommand !== "function") {
|
|
231
|
+
throw new RecordingUnavailableError("session cannot run commands (no exec/execCommand) — recording finalize unavailable");
|
|
232
|
+
}
|
|
233
|
+
// F8: size-gate on the box before reading into memory.
|
|
234
|
+
const sizeOut = (await run(s, `bash -lc ${shq(`stat -c %s ${proc.boxPath} 2>/dev/null || echo MISSING`)}`, proc.runAs)).trim();
|
|
235
|
+
const sizeLine = sizeOut.split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? "MISSING";
|
|
236
|
+
if (sizeLine === "MISSING" || sizeLine === "") {
|
|
237
|
+
throw new RecordingError(`recording file missing on box: ${proc.boxPath}`, "box-death");
|
|
238
|
+
}
|
|
239
|
+
const size = Number(sizeLine);
|
|
240
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
241
|
+
throw new RecordingError(`recording file empty on box: ${proc.boxPath}`, "ffmpeg-error");
|
|
242
|
+
}
|
|
243
|
+
if (size > maxBytes) {
|
|
244
|
+
throw new RecordingError(`recording ${size}B exceeds max ${maxBytes}B`, "max-bytes-exceeded");
|
|
245
|
+
}
|
|
246
|
+
// Read the bytes via a DIRECT exec (base64, no output truncation), so the
|
|
247
|
+
// absolute /tmp path is NOT run through the workspace-root-scoped readFile guard.
|
|
248
|
+
const STOP_YIELD = 60_000; // a large recording's base64 read may take longer than the default exec yield.
|
|
249
|
+
const encoded = stripExecBanner(
|
|
250
|
+
await run(s, `bash -lc ${shq(`base64 ${proc.boxPath}`)}`, proc.runAs, STOP_YIELD, null),
|
|
251
|
+
);
|
|
252
|
+
const base64 = encoded.replace(/\s+/g, "");
|
|
253
|
+
if (base64.length === 0) {
|
|
254
|
+
throw new RecordingError(`recording read returned 0 bytes: ${proc.boxPath}`, "ffmpeg-error");
|
|
255
|
+
}
|
|
256
|
+
let bytes: Uint8Array;
|
|
257
|
+
try {
|
|
258
|
+
bytes = Uint8Array.from(Buffer.from(base64, "base64"));
|
|
259
|
+
} catch (error) {
|
|
260
|
+
throw new RecordingError(`recording base64 decode failed: ${error instanceof Error ? error.message : String(error)}`, "ffmpeg-error");
|
|
261
|
+
}
|
|
262
|
+
if (bytes.length === 0) {
|
|
263
|
+
throw new RecordingError(`recording read returned 0 bytes: ${proc.boxPath}`, "ffmpeg-error");
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
bytes,
|
|
267
|
+
contentType: contentTypeForCodec(proc.codec),
|
|
268
|
+
sizeBytes: bytes.length,
|
|
269
|
+
durationSeconds: Math.max(0, Math.round((Date.now() - proc.startedAt) / 1000)),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Delete the box artifacts. F9: call this ONLY after the storage PUT confirmed
|
|
275
|
+
* and the `available` row committed — never before. Best-effort; never throws.
|
|
276
|
+
*/
|
|
277
|
+
export async function deleteRecordingArtifacts(session: unknown, proc: RecordingProcess): Promise<void> {
|
|
278
|
+
const s = session as RecordingSession;
|
|
279
|
+
const logFile = proc.boxPath.replace(/\.(mp4|webm)$/, ".log");
|
|
280
|
+
await run(s, `rm -f ${proc.boxPath} ${proc.pidFile} ${logFile}`, proc.runAs).catch(() => undefined);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The storage object key for a recording artifact (parallels the file-asset layout). */
|
|
284
|
+
export function recordingStorageKey(workspaceId: string, sessionId: string, recordingId: string, codec: RecordingCodec): string {
|
|
285
|
+
return `recordings/${workspaceId}/${sessionId}/${recordingId}.${extForCodec(codec)}`;
|
|
286
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// `makeActiveBackendResolver` — builds the `resolveActiveBackend` closure the
|
|
2
|
+
// `RoutingSandboxSession` calls to turn an active pointer into a live backend
|
|
3
|
+
// session (M7). It is the heterogeneous-dispatch core: a pointer's target is
|
|
4
|
+
// EITHER the session's own group sandbox (the default, `activeSandboxId === null`)
|
|
5
|
+
// OR a first-class named sandbox the session swapped to — a sibling Modal box or
|
|
6
|
+
// a selfhosted machine.
|
|
7
|
+
//
|
|
8
|
+
// This lives in the agent-loop-free leaf and depends ONLY on injected closures +
|
|
9
|
+
// the selfhosted session builder, so the API/worker wire it to the real DB
|
|
10
|
+
// (`getSandbox`/`getEnrollment`/`readActiveSandbox`) + the live NATS ControlRpc
|
|
11
|
+
// without coupling the leaf to `@opengeni/db`.
|
|
12
|
+
//
|
|
13
|
+
// The DEFAULT target (the group box) is supplied as an already-established
|
|
14
|
+
// session (the turn box `resumeBoxForTurn` produced, or the Channel-A established
|
|
15
|
+
// handle) — the proxy does NOT re-establish it (the lease owns its lifecycle). A
|
|
16
|
+
// NON-DEFAULT selfhosted target builds a `SelfhostedSession` bound to the target's
|
|
17
|
+
// enrollment agentId, fenced under the swap's active_epoch. A non-default MODAL
|
|
18
|
+
// target is established via the injected `establishModalTarget` resolver (the
|
|
19
|
+
// API/worker pass a resume-by-id closure for the sibling box's lease).
|
|
20
|
+
|
|
21
|
+
import { buildSelfhostedBackendSession, type SelfhostedRelayConfig } from "../selfhosted/session";
|
|
22
|
+
import type { ControlRpc } from "../selfhosted/control-rpc";
|
|
23
|
+
import type {
|
|
24
|
+
ActivePointer,
|
|
25
|
+
RoutableBackendSession,
|
|
26
|
+
ResolvedActiveBackend,
|
|
27
|
+
} from "./routing-session";
|
|
28
|
+
|
|
29
|
+
/** The structural slice of a first-class sandbox the resolver reads (mirror of
|
|
30
|
+
* `@opengeni/db`'s `SandboxRecord`; structural so the leaf does not import DB). */
|
|
31
|
+
export interface RoutableSandbox {
|
|
32
|
+
id: string;
|
|
33
|
+
kind: "modal" | "selfhosted" | string;
|
|
34
|
+
name: string;
|
|
35
|
+
/** For a selfhosted sandbox this is its enrollment id (== the agent id the
|
|
36
|
+
* control-plane subject `agent.<ws>.<id>.rpc` addresses). Null for modal. */
|
|
37
|
+
enrollmentId: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ActiveBackendResolverDeps {
|
|
41
|
+
/** The workspace the session belongs to (the control-plane subject scope). */
|
|
42
|
+
workspaceId: string;
|
|
43
|
+
/** The session's own group sandbox session — the DEFAULT target
|
|
44
|
+
* (`activeSandboxId === null`). Already established (lease-owned); the proxy
|
|
45
|
+
* never re-establishes it. */
|
|
46
|
+
defaultBackend: RoutableBackendSession;
|
|
47
|
+
/** A label for the default backend (its backend id: "modal"/"selfhosted"/…). */
|
|
48
|
+
defaultKind: string;
|
|
49
|
+
/** Look up a first-class sandbox by id (the swap target). Returns null when the
|
|
50
|
+
* id is unknown or not in this workspace (the caller 409s the swap). */
|
|
51
|
+
getSandbox(sandboxId: string): Promise<RoutableSandbox | null>;
|
|
52
|
+
/** Build a live `ControlRpc` for the selfhosted control plane (the request-
|
|
53
|
+
* scoped NATS connection). Returns a ControlRpc whose offline/timeout maps to
|
|
54
|
+
* agent_offline/agent_reconnecting (never a NotFound). */
|
|
55
|
+
controlRpcFactory(): ControlRpc;
|
|
56
|
+
/** The relay-URL shape config for selfhosted stream endpoints. */
|
|
57
|
+
relay: SelfhostedRelayConfig;
|
|
58
|
+
/** Establish (resume-by-id) a NON-DEFAULT modal target's box session for a swap.
|
|
59
|
+
* Supplied by the API/worker (a closure over the sibling sandbox's lease). When
|
|
60
|
+
* absent, a modal swap target surfaces as unsupported (the caller validated
|
|
61
|
+
* liveness, so this is the "modal swap not wired in this context" guard). */
|
|
62
|
+
establishModalTarget?: (sandbox: RoutableSandbox) => Promise<RoutableBackendSession>;
|
|
63
|
+
/** Override the selfhosted control-op timeout (tests). */
|
|
64
|
+
selfhostedTimeoutMs?: number;
|
|
65
|
+
/**
|
|
66
|
+
* The run's declared sandbox environment — the SAME `Record<string,string>` the
|
|
67
|
+
* worker turn threads into the agent's TARGET manifest (and into the group box at
|
|
68
|
+
* create). Threaded into a selfhosted swap target's session so its
|
|
69
|
+
* `state.manifest.environment` EQUALS the turn's, making the SDK's per-turn
|
|
70
|
+
* provided-session manifest-env delta empty (validateNoEnvironmentDelta).
|
|
71
|
+
* WITHOUT this a pin-to-vm turn throws "Live sandbox sessions cannot change
|
|
72
|
+
* manifest environment variables". Omitted → `{}` (the test/negotiation path).
|
|
73
|
+
*/
|
|
74
|
+
environment?: Record<string, string>;
|
|
75
|
+
/**
|
|
76
|
+
* A pre-established selfhosted session to PIN for the STEADY-STATE machine
|
|
77
|
+
* pointer (the worker turn's machine-primary path, Stage D). When the pointer
|
|
78
|
+
* targets THIS sandbox at THIS epoch, the resolver returns this SAME instance
|
|
79
|
+
* instead of building a fresh `SelfhostedSession`. This is the instance-identity
|
|
80
|
+
* pin: the SDK reads/writes `state.manifest` at turn START via the proxy's `state`
|
|
81
|
+
* getter (which reads the default/last-resolved backend's state) and then reads it
|
|
82
|
+
* per op via this resolver — those MUST land on ONE SelfhostedSession/manifest, or
|
|
83
|
+
* a turn-start manifest write is invisible to the per-op reads (two-instance
|
|
84
|
+
* divergence). A swap AWAY (a different sandbox id, or the same id at a moved epoch)
|
|
85
|
+
* falls through to a fresh build under the new epoch. Omitted for the API/live-swap
|
|
86
|
+
* path (which always builds fresh — it has no pre-established turn session).
|
|
87
|
+
*/
|
|
88
|
+
pinnedSelfhosted?: { sandboxId: string; epoch: number; session: RoutableBackendSession };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Thrown when a swap target cannot be resolved (unknown sandbox, or a modal
|
|
92
|
+
* target with no establisher in this context). The caller maps it to a 409. */
|
|
93
|
+
export class ActiveBackendUnresolvableError extends Error {
|
|
94
|
+
readonly name = "ActiveBackendUnresolvableError";
|
|
95
|
+
constructor(message: string) {
|
|
96
|
+
super(message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build the `resolveActiveBackend(pointer)` closure for a `RoutingSandboxSession`.
|
|
102
|
+
* The returned closure is re-invoked by the proxy whenever the active_epoch moves
|
|
103
|
+
* (the per-epoch cache miss), so it must be cheap-and-correct for the steady-state
|
|
104
|
+
* (default pointer → the already-established group box) and build a fresh backend
|
|
105
|
+
* for a swap target.
|
|
106
|
+
*
|
|
107
|
+
* - `activeSandboxId === null` → the default group backend (no re-establish).
|
|
108
|
+
* - a selfhosted target → a `SelfhostedSession` bound to the enrollment agentId,
|
|
109
|
+
* fenced under `pointer.activeEpoch` (echoed on every ControlRequest so the
|
|
110
|
+
* agent can reject a stale op with ERROR_CODE_FENCED — the swap-race fence).
|
|
111
|
+
* - a modal target → `establishModalTarget` (the resume-by-id closure), else
|
|
112
|
+
* unresolvable.
|
|
113
|
+
*/
|
|
114
|
+
export function makeActiveBackendResolver(
|
|
115
|
+
deps: ActiveBackendResolverDeps,
|
|
116
|
+
): (pointer: ActivePointer) => Promise<ResolvedActiveBackend> {
|
|
117
|
+
return async (pointer: ActivePointer): Promise<ResolvedActiveBackend> => {
|
|
118
|
+
// The DEFAULT target: the session's own group sandbox (backward-compat). The
|
|
119
|
+
// proxy routes to the already-established box; the lease owns its lifecycle.
|
|
120
|
+
if (pointer.activeSandboxId === null) {
|
|
121
|
+
return { session: deps.defaultBackend, sandboxId: null, kind: deps.defaultKind };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// INSTANCE PIN (Stage D machine-primary): the steady-state machine pointer
|
|
125
|
+
// returns the pre-established turn session BY REFERENCE — never a fresh build —
|
|
126
|
+
// so the turn-start manifest write + the per-op reads land on ONE
|
|
127
|
+
// SelfhostedSession/manifest. Matched on BOTH the sandbox id AND the epoch: a
|
|
128
|
+
// swap away (different id) or a swap-back (same id, higher epoch) falls through
|
|
129
|
+
// to a fresh build fenced under the CURRENT epoch (the stale pinned instance is
|
|
130
|
+
// fenced at the old epoch and must not be reused).
|
|
131
|
+
if (
|
|
132
|
+
deps.pinnedSelfhosted
|
|
133
|
+
&& pointer.activeSandboxId === deps.pinnedSelfhosted.sandboxId
|
|
134
|
+
&& pointer.activeEpoch === deps.pinnedSelfhosted.epoch
|
|
135
|
+
) {
|
|
136
|
+
return { session: deps.pinnedSelfhosted.session, sandboxId: pointer.activeSandboxId, kind: "selfhosted" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const sandbox = await deps.getSandbox(pointer.activeSandboxId);
|
|
140
|
+
if (!sandbox) {
|
|
141
|
+
throw new ActiveBackendUnresolvableError(
|
|
142
|
+
`active sandbox ${pointer.activeSandboxId} not found in workspace ${deps.workspaceId}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (sandbox.kind === "selfhosted") {
|
|
147
|
+
if (!sandbox.enrollmentId) {
|
|
148
|
+
throw new ActiveBackendUnresolvableError(
|
|
149
|
+
`selfhosted sandbox ${sandbox.id} has no enrollment (agent id) to address`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
// Build a request-scoped selfhosted client bound to the target's workspace +
|
|
153
|
+
// enrollment agentId, fenced under the swap's active_epoch. The agent echoes
|
|
154
|
+
// the epoch and rejects a stale op with ERROR_CODE_FENCED → the proxy
|
|
155
|
+
// re-resolves + retries against the new active sandbox. The SAME factory the
|
|
156
|
+
// worker turn's machine-primary establish branch uses (one build shape).
|
|
157
|
+
const { session } = await buildSelfhostedBackendSession({
|
|
158
|
+
workspaceId: deps.workspaceId,
|
|
159
|
+
relay: deps.relay,
|
|
160
|
+
controlRpcFactory: deps.controlRpcFactory,
|
|
161
|
+
agentId: sandbox.enrollmentId,
|
|
162
|
+
epoch: pointer.activeEpoch,
|
|
163
|
+
...(deps.selfhostedTimeoutMs !== undefined ? { timeoutMs: deps.selfhostedTimeoutMs } : {}),
|
|
164
|
+
// The turn's declared environment → the session's manifest.environment, so
|
|
165
|
+
// the SDK's per-turn manifest-env delta is empty (no "cannot change manifest
|
|
166
|
+
// environment variables" throw on a pin-to-vm turn).
|
|
167
|
+
...(deps.environment !== undefined ? { environment: deps.environment } : {}),
|
|
168
|
+
// The session's working directory (per-session pointer) → the path/cwd base
|
|
169
|
+
// for this selfhosted backend. Absent/empty ⇒ the default workspace_root.
|
|
170
|
+
...(pointer.workingDir ? { workingDir: pointer.workingDir } : {}),
|
|
171
|
+
});
|
|
172
|
+
return { session: session as RoutableBackendSession, sandboxId: sandbox.id, kind: "selfhosted" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (sandbox.kind === "modal") {
|
|
176
|
+
if (!deps.establishModalTarget) {
|
|
177
|
+
throw new ActiveBackendUnresolvableError(
|
|
178
|
+
`modal swap target ${sandbox.id} cannot be established in this context (no establisher wired)`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const session = await deps.establishModalTarget(sandbox);
|
|
182
|
+
return { session, sandboxId: sandbox.id, kind: "modal" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw new ActiveBackendUnresolvableError(
|
|
186
|
+
`unsupported swap target kind "${sandbox.kind}" for sandbox ${sandbox.id}`,
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
}
|