@opengeni/runtime 0.6.1 → 0.7.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-DYBF7ZEB.js → chunk-RYDCVTMW.js} +1958 -207
- package/dist/chunk-RYDCVTMW.js.map +1 -0
- package/dist/{index-BTo_bZvN.d.ts → index-DIiiwQY2.d.ts} +689 -52
- package/dist/index.d.ts +293 -123
- package/dist/index.js +1819 -790
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +79 -3
- package/package.json +21 -20
- package/src/codex-tool-search.ts +185 -31
- package/src/context-compaction.ts +78 -243
- package/src/history-sanitizer.ts +172 -20
- package/src/index.ts +2573 -898
- package/src/metrics.ts +28 -2
- package/src/sandbox/capabilities.ts +12 -3
- package/src/sandbox/channel-a.ts +420 -72
- package/src/sandbox/display-stack.ts +18 -4
- package/src/sandbox/errors.ts +13 -0
- package/src/sandbox/index.ts +398 -112
- package/src/sandbox/op-correlation.ts +70 -0
- package/src/sandbox/providers/index.ts +9 -3
- package/src/sandbox/providers/modal.ts +84 -14
- package/src/sandbox/providers/selfhosted.ts +1 -4
- package/src/sandbox/recording.ts +146 -90
- package/src/sandbox/routing/backend-resolver.ts +144 -10
- package/src/sandbox/routing/routing-session.ts +129 -25
- package/src/sandbox/select.ts +60 -8
- package/src/sandbox/selfhosted/capabilities.ts +59 -25
- package/src/sandbox/selfhosted/control-rpc.ts +160 -28
- package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
- package/src/sandbox/selfhosted/op-observer.ts +54 -0
- package/src/sandbox/selfhosted/op-stream.ts +801 -0
- package/src/sandbox/selfhosted/op-testing.ts +468 -0
- package/src/sandbox/selfhosted/op-transport.ts +151 -0
- package/src/sandbox/selfhosted/retry-policy.ts +177 -0
- package/src/sandbox/selfhosted/session.ts +498 -55
- package/src/sandbox/selfhosted/testing.ts +60 -19
- package/src/sandbox/stream-port.ts +17 -5
- package/src/sandbox/stream-token.ts +8 -2
- package/src/sandbox/terminal-server.ts +18 -4
- package/src/sandbox-computer.ts +332 -70
- package/src/usage-telemetry.ts +73 -0
- package/dist/chunk-DYBF7ZEB.js.map +0 -1
- package/src/image-history.ts +0 -149
- package/src/screenshot-omitted-card.ts +0 -15
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Durable op-identity correlation (op-stream ruling B1): the DURABLE op id a
|
|
2
|
+
// sandbox transport op carries is `{sdk_tool_call_id}:{ordinal}` — minted ABOVE
|
|
3
|
+
// the transport, at the semantic layer. The tool call id lives in the model's
|
|
4
|
+
// function_call (persisted run history), so a re-dispatched turn re-executes the
|
|
5
|
+
// SAME function_call with the SAME call id, and the transport's idempotent
|
|
6
|
+
// OpStart turns an at-least-once re-execution into an ATTACH instead of a
|
|
7
|
+
// re-run. The ordinal is the deterministic position of the physical sub-op
|
|
8
|
+
// inside one tool invocation (a tool that performs read→write→rm mints :0, :1,
|
|
9
|
+
// :2), so a re-executed invocation re-mints identical ids.
|
|
10
|
+
//
|
|
11
|
+
// The correlation rides an AsyncLocalStorage bound around the SDK tool's
|
|
12
|
+
// `execute` (see `withExecOpCorrelation` in the runtime barrel): the SDK's tool
|
|
13
|
+
// machinery passes `details.toolCall` (with `callId`) into every function-tool
|
|
14
|
+
// invocation, and the shell capability's `configureTools` hook lets us wrap
|
|
15
|
+
// exec_command without touching the SDK. Per-async-chain storage keeps PARALLEL
|
|
16
|
+
// tool calls correctly separated.
|
|
17
|
+
//
|
|
18
|
+
// This module is deliberately dependency-free (node:async_hooks only) so the
|
|
19
|
+
// agent-loop-free sandbox leaf may import it: the leaf READS the context; only
|
|
20
|
+
// the runtime barrel (which owns the SDK imports) BINDS it.
|
|
21
|
+
|
|
22
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
23
|
+
|
|
24
|
+
interface ToolCallCorrelation {
|
|
25
|
+
/** The sanitized sdk tool call id (a legal NATS subject-token fragment). */
|
|
26
|
+
callId: string;
|
|
27
|
+
/** The next sub-op ordinal within this tool invocation (mutable). */
|
|
28
|
+
ordinal: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const storage = new AsyncLocalStorage<ToolCallCorrelation>();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sanitize a tool call id into a legal NATS subject TOKEN fragment. The op id
|
|
35
|
+
* is interpolated into the per-op frame subject, whose tokens must not contain
|
|
36
|
+
* whitespace/control characters or the subject-structure characters (`.`,
|
|
37
|
+
* `*`, `>`); the runner refuses illegal ids loudly. The mapping is INJECTIVE
|
|
38
|
+
* (each disallowed char becomes `_<hex>_`), so two distinct call ids can never
|
|
39
|
+
* collide into one op id — a collision would merge two different execs through
|
|
40
|
+
* the idempotent-OpStart dedup and return the wrong result.
|
|
41
|
+
*/
|
|
42
|
+
export function sanitizeOpIdToken(raw: string): string {
|
|
43
|
+
return raw.replace(/[^A-Za-z0-9_-]/g, (c) => `_${c.charCodeAt(0).toString(16)}_`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Bind a tool-call correlation context around `fn` (the tool's execute). Every
|
|
48
|
+
* durable op id minted inside — however deep in the transport — is
|
|
49
|
+
* `{callId}:{ordinal}` with ordinals starting at 0 per invocation.
|
|
50
|
+
*/
|
|
51
|
+
export function runWithToolCallCorrelation<T>(callId: string, fn: () => T): T {
|
|
52
|
+
return storage.run({ callId: sanitizeOpIdToken(callId), ordinal: 0 }, fn);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mint the next durable op id for the current tool invocation, or null when no
|
|
57
|
+
* correlation context is bound (a non-tool caller, e.g. Channel-A structural
|
|
58
|
+
* exec). Callers fall back to a random unique id — safe (never collides, never
|
|
59
|
+
* wrongly dedups), merely not stable across a turn re-dispatch, which degrades
|
|
60
|
+
* that one op to today's at-least-once semantics.
|
|
61
|
+
*/
|
|
62
|
+
export function nextDurableOpId(): string | null {
|
|
63
|
+
const context = storage.getStore();
|
|
64
|
+
if (!context) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const ordinal = context.ordinal;
|
|
68
|
+
context.ordinal += 1;
|
|
69
|
+
return `${context.callId}:${ordinal}`;
|
|
70
|
+
}
|
|
@@ -66,16 +66,22 @@ export function assertProviderRegistryInvariants(): void {
|
|
|
66
66
|
for (const backend of SandboxBackend.options) {
|
|
67
67
|
const registration = PROVIDER_REGISTRY[backend];
|
|
68
68
|
if (registration.backend !== backend) {
|
|
69
|
-
throw new Error(
|
|
69
|
+
throw new Error(
|
|
70
|
+
`PROVIDER_REGISTRY["${backend}"].backend mismatch (got "${registration.backend}")`,
|
|
71
|
+
);
|
|
70
72
|
}
|
|
71
73
|
if (registration.descriptor.backend !== backend) {
|
|
72
|
-
throw new Error(
|
|
74
|
+
throw new Error(
|
|
75
|
+
`PROVIDER_REGISTRY["${backend}"].descriptor.backend mismatch (got "${registration.descriptor.backend}")`,
|
|
76
|
+
);
|
|
73
77
|
}
|
|
74
78
|
if (backend === "none") {
|
|
75
79
|
// "none" has no SDK client (build returns undefined); the descriptor
|
|
76
80
|
// backendId "none" is self-consistent.
|
|
77
81
|
if (registration.descriptor.backendId !== "none") {
|
|
78
|
-
throw new Error(
|
|
82
|
+
throw new Error(
|
|
83
|
+
`"none" descriptor.backendId must be "none" (got "${registration.descriptor.backendId}")`,
|
|
84
|
+
);
|
|
79
85
|
}
|
|
80
86
|
continue;
|
|
81
87
|
}
|
|
@@ -6,7 +6,14 @@ import { SandboxConfigError } from "../errors";
|
|
|
6
6
|
import type { ProviderRegistration } from "./types";
|
|
7
7
|
|
|
8
8
|
const MODAL_ORPHAN_SWEEP_LIMIT = 50;
|
|
9
|
-
|
|
9
|
+
// A provider box is invisible to the lease until Modal create + manifest
|
|
10
|
+
// materialization returns and the creation callback records its instance id.
|
|
11
|
+
// Production baseline (2026-07-15, all 8 turn workers): 155/155 completed creates
|
|
12
|
+
// finished under 10s (154 under 2.5s). Two minutes is a 12x observed-max buffer,
|
|
13
|
+
// while avoiding the former 30-minute retention of boxes abandoned by a rolling
|
|
14
|
+
// worker restart. The live-instance guard below remains authoritative: once a box
|
|
15
|
+
// is recorded by any lease, age and missing/stale tags can never terminate it.
|
|
16
|
+
const MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS = 2 * 60_000;
|
|
10
17
|
|
|
11
18
|
export type ModalSandboxAttribution = {
|
|
12
19
|
leaseId: string;
|
|
@@ -31,7 +38,9 @@ export type ModalOrphanSweepResult = {
|
|
|
31
38
|
skipped: number;
|
|
32
39
|
};
|
|
33
40
|
|
|
34
|
-
export function modalSandboxAttributionEnvironment(
|
|
41
|
+
export function modalSandboxAttributionEnvironment(
|
|
42
|
+
input: ModalSandboxAttribution,
|
|
43
|
+
): Record<string, string> {
|
|
35
44
|
return {
|
|
36
45
|
OPENGENI_SANDBOX_LEASE_ID: input.leaseId,
|
|
37
46
|
OPENGENI_SANDBOX_GROUP_ID: input.sandboxGroupId,
|
|
@@ -39,7 +48,9 @@ export function modalSandboxAttributionEnvironment(input: ModalSandboxAttributio
|
|
|
39
48
|
};
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
export function modalSandboxAttributionTags(
|
|
51
|
+
export function modalSandboxAttributionTags(
|
|
52
|
+
input: ModalSandboxAttribution,
|
|
53
|
+
): Record<string, string> {
|
|
43
54
|
return {
|
|
44
55
|
opengeni: "true",
|
|
45
56
|
opengeni_lease_id: input.leaseId,
|
|
@@ -70,10 +81,11 @@ export const modalProvider: ProviderRegistration = {
|
|
|
70
81
|
sandboxCreateTimeoutS: Math.ceil(settings.sandboxWarmingTimeoutMs / 1000),
|
|
71
82
|
exposedPorts,
|
|
72
83
|
env: environment,
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
84
|
+
// A registry image's own CMD is not a sandbox keepalive contract (for
|
|
85
|
+
// example, python:3.12-slim can exit immediately). Keep the provider's
|
|
86
|
+
// control process alive so exec/resume remains available; Modal's hard
|
|
87
|
+
// timeout and explicit OpenGeni teardown still own the box lifetime.
|
|
88
|
+
useSleepCmd: true,
|
|
77
89
|
};
|
|
78
90
|
// gap-fill (module 03 §4.1): these SDK options were previously unmapped.
|
|
79
91
|
// ALWAYS pin idleTimeoutMs (sandbox-file-persistence): an UNSET idle timeout
|
|
@@ -206,7 +218,9 @@ export function resolveModalImageSelector(settings: Settings): ModalImageSelecto
|
|
|
206
218
|
}
|
|
207
219
|
const registryImage = cachedModalRegistryImage(settings);
|
|
208
220
|
return registryImage
|
|
209
|
-
? ModalImageSelector.fromImage(
|
|
221
|
+
? ModalImageSelector.fromImage(
|
|
222
|
+
registryImage as Parameters<typeof ModalImageSelector.fromImage>[0],
|
|
223
|
+
)
|
|
210
224
|
: ModalImageSelector.fromTag(settings.modalImageRef);
|
|
211
225
|
}
|
|
212
226
|
|
|
@@ -216,7 +230,9 @@ export function __resetModalRegistryImageCacheForTest(): void {
|
|
|
216
230
|
inFlightRegistryImages.clear();
|
|
217
231
|
}
|
|
218
232
|
|
|
219
|
-
function modalClientOptions(
|
|
233
|
+
function modalClientOptions(
|
|
234
|
+
settings: Settings,
|
|
235
|
+
): ConstructorParameters<ModalModule["ModalClient"]>[0] {
|
|
220
236
|
return {
|
|
221
237
|
...(settings.modalTokenId ? { tokenId: settings.modalTokenId } : {}),
|
|
222
238
|
...(settings.modalTokenSecret ? { tokenSecret: settings.modalTokenSecret } : {}),
|
|
@@ -248,7 +264,10 @@ export async function tagModalSandbox(
|
|
|
248
264
|
}
|
|
249
265
|
}
|
|
250
266
|
|
|
251
|
-
export async function terminateModalSandboxById(
|
|
267
|
+
export async function terminateModalSandboxById(
|
|
268
|
+
settings: Settings,
|
|
269
|
+
sandboxId: string,
|
|
270
|
+
): Promise<boolean> {
|
|
252
271
|
if (!sandboxId) {
|
|
253
272
|
return true;
|
|
254
273
|
}
|
|
@@ -291,14 +310,22 @@ function tagsFromInfo(info: ModalSandboxInfo): Record<string, string> {
|
|
|
291
310
|
}
|
|
292
311
|
|
|
293
312
|
function sandboxCreatedAtMs(info: ModalSandboxInfo): number | null {
|
|
294
|
-
if (
|
|
313
|
+
if (
|
|
314
|
+
typeof info.createdAt !== "number" ||
|
|
315
|
+
!Number.isFinite(info.createdAt) ||
|
|
316
|
+
info.createdAt <= 0
|
|
317
|
+
) {
|
|
295
318
|
return null;
|
|
296
319
|
}
|
|
297
320
|
// Modal protobuf timestamps in this SDK are seconds as doubles.
|
|
298
|
-
return info.createdAt < 10_000_000_000
|
|
321
|
+
return info.createdAt < 10_000_000_000
|
|
322
|
+
? Math.floor(info.createdAt * 1000)
|
|
323
|
+
: Math.floor(info.createdAt);
|
|
299
324
|
}
|
|
300
325
|
|
|
301
|
-
function attributionKey(
|
|
326
|
+
function attributionKey(
|
|
327
|
+
input: Pick<ModalSandboxAttribution, "leaseId" | "workspaceId" | "sandboxGroupId">,
|
|
328
|
+
): string {
|
|
302
329
|
return `${input.workspaceId}:${input.sandboxGroupId}:${input.leaseId}`;
|
|
303
330
|
}
|
|
304
331
|
|
|
@@ -316,6 +343,18 @@ export async function sweepModalOrphanSandboxes(
|
|
|
316
343
|
const maxTerminations = options.maxTerminations ?? MODAL_ORPHAN_SWEEP_LIMIT;
|
|
317
344
|
const unattributedGraceMs = options.unattributedGraceMs ?? MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS;
|
|
318
345
|
const liveByAttribution = new Map(liveLeases.map((lease) => [attributionKey(lease), lease]));
|
|
346
|
+
// LIVE-INSTANCE GUARD: a box that any live lease's envelope points at is NEVER
|
|
347
|
+
// an orphan, whatever its tags say. Tags are best-effort attribution (setTags
|
|
348
|
+
// is a separate call after create and can fail or lag); the lease envelope is
|
|
349
|
+
// the source of truth the turn path actually resumes by. Judging by tags alone
|
|
350
|
+
// terminated a LIVE box mid-turn at exactly creation+30min (staging session
|
|
351
|
+
// e644e8a8, 2026-07-06) — the box's unpushed work was unrecoverable because
|
|
352
|
+
// nothing outside the reaper drain persists /workspace.
|
|
353
|
+
const liveByInstanceId = new Map(
|
|
354
|
+
liveLeases
|
|
355
|
+
.filter((lease) => lease.instanceId)
|
|
356
|
+
.map((lease) => [lease.instanceId as string, lease]),
|
|
357
|
+
);
|
|
319
358
|
const ownedClient = options.client ? null : await createModalClient(settings);
|
|
320
359
|
const modal = (options.client ?? ownedClient)! as ModalCpListClient;
|
|
321
360
|
try {
|
|
@@ -350,9 +389,40 @@ export async function sweepModalOrphanSandboxes(
|
|
|
350
389
|
const leaseId = tags.opengeni_lease_id;
|
|
351
390
|
const workspaceId = tags.opengeni_workspace_id;
|
|
352
391
|
const sandboxGroupId = tags.opengeni_sandbox_group_id;
|
|
392
|
+
const liveByInstance = info.id ? liveByInstanceId.get(info.id) : undefined;
|
|
393
|
+
if (liveByInstance) {
|
|
394
|
+
// Live-instance guard (see above): a live lease resumes this exact box
|
|
395
|
+
// by id — hard-skip it, and HEAL its attribution tags when they are
|
|
396
|
+
// missing/stale so it stops looking sweep-eligible. Best-effort: a
|
|
397
|
+
// failed re-tag must never fail the sweep (the guard, not the tags,
|
|
398
|
+
// is what protects the box now).
|
|
399
|
+
if (
|
|
400
|
+
leaseId !== liveByInstance.leaseId ||
|
|
401
|
+
workspaceId !== liveByInstance.workspaceId ||
|
|
402
|
+
sandboxGroupId !== liveByInstance.sandboxGroupId
|
|
403
|
+
) {
|
|
404
|
+
try {
|
|
405
|
+
const sandbox = await modal.sandboxes.fromId(info.id);
|
|
406
|
+
await sandbox.setTags(
|
|
407
|
+
modalSandboxAttributionTags({
|
|
408
|
+
leaseId: liveByInstance.leaseId,
|
|
409
|
+
workspaceId: liveByInstance.workspaceId,
|
|
410
|
+
sandboxGroupId: liveByInstance.sandboxGroupId,
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
} catch {
|
|
414
|
+
// Tag healing is opportunistic; the instance guard already
|
|
415
|
+
// protects this box on every future sweep pass.
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
skipped += 1;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
353
421
|
let reason: ModalOrphanSweepTermination["reason"] | null = null;
|
|
354
422
|
if (leaseId && workspaceId && sandboxGroupId) {
|
|
355
|
-
const live = liveByAttribution.get(
|
|
423
|
+
const live = liveByAttribution.get(
|
|
424
|
+
attributionKey({ leaseId, workspaceId, sandboxGroupId }),
|
|
425
|
+
);
|
|
356
426
|
if (!live || (live.instanceId && live.instanceId !== info.id)) {
|
|
357
427
|
reason = "stale_attribution";
|
|
358
428
|
}
|
|
@@ -20,10 +20,7 @@ import {
|
|
|
20
20
|
type ControlRpc,
|
|
21
21
|
type NatsRequestConnection,
|
|
22
22
|
} from "../selfhosted/control-rpc";
|
|
23
|
-
import {
|
|
24
|
-
SelfhostedSandboxClient,
|
|
25
|
-
type SelfhostedRelayConfig,
|
|
26
|
-
} from "../selfhosted/session";
|
|
23
|
+
import { SelfhostedSandboxClient, type SelfhostedRelayConfig } from "../selfhosted/session";
|
|
27
24
|
import type { ProviderRegistration } from "./types";
|
|
28
25
|
|
|
29
26
|
/**
|
package/src/sandbox/recording.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @opengeni/runtime/sandbox — the recording loop (P4.3).
|
|
2
2
|
//
|
|
3
|
-
// ffmpeg x11grab on :0 → mp4/webm artifact on the box →
|
|
3
|
+
// ffmpeg x11grab on :0 → mp4/webm artifact on the box → direct scoped PUT to
|
|
4
4
|
// object storage → recording.available. The "agent films itself proving the fix"
|
|
5
5
|
// loop: ffmpeg reads exactly the :0 framebuffer the agent's computer-use draws to
|
|
6
6
|
// and the human watches over Channel B (zero projection).
|
|
@@ -9,22 +9,16 @@
|
|
|
9
9
|
// Temporal, NO worker RPC, NO actor. They live in the agent-loop-free leaf so the
|
|
10
10
|
// SAME process that already holds the resumed-by-id box (the agent turn's own
|
|
11
11
|
// activity for an on-turn recording, or the API in-process for an off-turn/manual
|
|
12
|
-
// finalize)
|
|
13
|
-
//
|
|
14
|
-
// activity result — the 256 MB-vs-payload-limit concern dissolves (F10).
|
|
12
|
+
// finalize) authorizes a direct box-to-storage upload. Recording bytes never
|
|
13
|
+
// enter worker memory or a Temporal payload (F10).
|
|
15
14
|
//
|
|
16
15
|
// ── Adversarial-review fixes folded in (module 05 §Adversarial) ──────────────
|
|
17
16
|
// F1 exec is OPTIONAL on Modal (only execCommand) — every command dual-paths.
|
|
18
|
-
// F3 exec/execCommand YIELDS — the
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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.
|
|
17
|
+
// F3 exec/execCommand YIELDS — the stop and direct-upload commands are bounded.
|
|
18
|
+
// F8 size is gated on the box first (stat); over-limit artifacts fail instead
|
|
19
|
+
// of being uploaded partially.
|
|
20
|
+
// FR curl reads the absolute /tmp artifact in the box and sends it directly to
|
|
21
|
+
// the short-lived scoped URL. No workspace-root read API is involved.
|
|
28
22
|
// F9 the box file is deleted ONLY after the storage PUT confirms — never
|
|
29
23
|
// before (so a failed upload leaves the bytes recoverable for a retry).
|
|
30
24
|
// F12 ffmpeg/x11vnc backgrounding does not block the yield (nohup … & echo $!).
|
|
@@ -54,24 +48,44 @@ export function extForCodec(codec: RecordingCodec): string {
|
|
|
54
48
|
|
|
55
49
|
/** No exec/execCommand on the session — the box cannot run ffmpeg. */
|
|
56
50
|
export class RecordingUnavailableError extends Error {
|
|
57
|
-
constructor(message: string) {
|
|
51
|
+
constructor(message: string) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "RecordingUnavailableError";
|
|
54
|
+
}
|
|
58
55
|
}
|
|
59
|
-
/** ffmpeg failed, the file is missing, or the
|
|
56
|
+
/** ffmpeg failed, the file is missing, or the artifact is over its size limit. */
|
|
60
57
|
export class RecordingError extends Error {
|
|
61
|
-
constructor(
|
|
58
|
+
constructor(
|
|
59
|
+
message: string,
|
|
60
|
+
readonly reason: "ffmpeg-error" | "box-death" | "max-bytes-exceeded" | "display-unavailable",
|
|
61
|
+
) {
|
|
62
62
|
super(message);
|
|
63
63
|
this.name = "RecordingError";
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// The structural slice of a provider session the recording loop drives. exec and
|
|
68
|
-
// execCommand are optional (Modal has only execCommand — F1)
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
// execCommand are optional (Modal has only execCommand — F1).
|
|
69
|
+
type ExecResultLike = {
|
|
70
|
+
output?: string;
|
|
71
|
+
stdout?: string;
|
|
72
|
+
stderr?: string;
|
|
73
|
+
exitCode?: number | null;
|
|
74
|
+
sessionId?: number;
|
|
75
|
+
};
|
|
71
76
|
type RecordingSession = {
|
|
72
|
-
exec?: (args: {
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
exec?: (args: {
|
|
78
|
+
cmd: string;
|
|
79
|
+
runAs?: string;
|
|
80
|
+
yieldTimeMs?: number;
|
|
81
|
+
maxOutputTokens?: number;
|
|
82
|
+
}) => Promise<ExecResultLike>;
|
|
83
|
+
execCommand?: (args: {
|
|
84
|
+
cmd: string;
|
|
85
|
+
runAs?: string;
|
|
86
|
+
yieldTimeMs?: number;
|
|
87
|
+
maxOutputTokens?: number;
|
|
88
|
+
}) => Promise<string>;
|
|
75
89
|
};
|
|
76
90
|
|
|
77
91
|
function shq(s: string): string {
|
|
@@ -80,11 +94,12 @@ function shq(s: string): string {
|
|
|
80
94
|
|
|
81
95
|
function resultOutput(result: ExecResultLike | string): string {
|
|
82
96
|
if (typeof result === "string") return result;
|
|
83
|
-
return [result.output, result.stderr, result.stdout]
|
|
97
|
+
return [result.output, result.stderr, result.stdout]
|
|
98
|
+
.filter((v): v is string => typeof v === "string" && v.length > 0)
|
|
99
|
+
.join("\n");
|
|
84
100
|
}
|
|
85
101
|
|
|
86
|
-
// Default per-command output cap (tokens).
|
|
87
|
-
// `null` (no truncation) so a base64-encoded recording is never clipped.
|
|
102
|
+
// Default per-command output cap (tokens). Recording transfer does not use stdout.
|
|
88
103
|
const DEFAULT_MAX_OUTPUT_TOKENS = 4_000;
|
|
89
104
|
|
|
90
105
|
async function run(
|
|
@@ -92,20 +107,23 @@ async function run(
|
|
|
92
107
|
cmd: string,
|
|
93
108
|
runAs?: string,
|
|
94
109
|
yieldTimeMs = EXEC_YIELD_MS,
|
|
95
|
-
maxOutputTokens
|
|
110
|
+
maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
|
|
96
111
|
): Promise<string> {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
const args: {
|
|
113
|
+
cmd: string;
|
|
114
|
+
runAs?: string;
|
|
115
|
+
yieldTimeMs?: number;
|
|
116
|
+
maxOutputTokens?: number;
|
|
117
|
+
} = { cmd, ...(runAs ? { runAs } : {}), yieldTimeMs, maxOutputTokens };
|
|
102
118
|
if (typeof session.exec === "function") {
|
|
103
119
|
return resultOutput(await session.exec(args));
|
|
104
120
|
}
|
|
105
121
|
if (typeof session.execCommand === "function") {
|
|
106
122
|
return resultOutput(await session.execCommand(args));
|
|
107
123
|
}
|
|
108
|
-
throw new RecordingUnavailableError(
|
|
124
|
+
throw new RecordingUnavailableError(
|
|
125
|
+
"session cannot run commands (no exec/execCommand) — recording unavailable",
|
|
126
|
+
);
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
// Extract the command body from a provider exec banner. Modal's execCommand
|
|
@@ -149,7 +167,10 @@ export type RecordingProcess = {
|
|
|
149
167
|
* block on the recording). A hard `-t <maxSeconds>` ceiling bounds a runaway file
|
|
150
168
|
* across a multi-day turn. Returns the handle the caller carries to stop+finalize.
|
|
151
169
|
*/
|
|
152
|
-
export async function startRecording(
|
|
170
|
+
export async function startRecording(
|
|
171
|
+
session: unknown,
|
|
172
|
+
input: StartRecordingInput,
|
|
173
|
+
): Promise<RecordingProcess> {
|
|
153
174
|
const s = session as RecordingSession;
|
|
154
175
|
const codec = input.codec ?? "h264-mp4";
|
|
155
176
|
const dimensions = input.dimensions ?? DEFAULT_DIMENSIONS;
|
|
@@ -162,9 +183,10 @@ export async function startRecording(session: unknown, input: StartRecordingInpu
|
|
|
162
183
|
const pidFile = `${tmp}/og-rec-${input.recordingId}.pid`;
|
|
163
184
|
const logFile = `${tmp}/og-rec-${input.recordingId}.log`;
|
|
164
185
|
const [w, h] = dimensions;
|
|
165
|
-
const enc =
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
const enc =
|
|
187
|
+
codec === "vp9-webm"
|
|
188
|
+
? `-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1`
|
|
189
|
+
: `-c:v libx264 -preset veryfast -pix_fmt yuv420p -movflags +faststart`;
|
|
168
190
|
const ffmpeg =
|
|
169
191
|
`nohup ffmpeg -hide_banner -loglevel error -f x11grab -draw_mouse 1 -framerate ${framerate} ` +
|
|
170
192
|
`-video_size ${w}x${h} -i ${display}.0 -t ${maxSeconds} ${enc} ${boxPath} ` +
|
|
@@ -194,45 +216,41 @@ export async function stopRecording(session: unknown, proc: RecordingProcess): P
|
|
|
194
216
|
await run(s, `bash -lc ${shq(wait)}`, proc.runAs, STOP_YIELD_MS).catch(() => undefined);
|
|
195
217
|
}
|
|
196
218
|
|
|
197
|
-
export type
|
|
198
|
-
bytes: Uint8Array;
|
|
219
|
+
export type RecordingArtifactMetadata = {
|
|
199
220
|
contentType: RecordingContentType;
|
|
200
221
|
sizeBytes: number;
|
|
201
222
|
durationSeconds: number;
|
|
202
223
|
};
|
|
203
224
|
|
|
204
225
|
/**
|
|
205
|
-
*
|
|
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.
|
|
226
|
+
* Inspect and size-gate the finalized recording while it is still on the box.
|
|
227
|
+
* No recording bytes cross into the worker process.
|
|
227
228
|
*/
|
|
228
|
-
export async function
|
|
229
|
+
export async function inspectRecordingArtifact(
|
|
230
|
+
session: unknown,
|
|
231
|
+
proc: RecordingProcess,
|
|
232
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
233
|
+
): Promise<RecordingArtifactMetadata> {
|
|
229
234
|
const s = session as RecordingSession;
|
|
230
235
|
if (typeof s.exec !== "function" && typeof s.execCommand !== "function") {
|
|
231
|
-
throw new RecordingUnavailableError(
|
|
236
|
+
throw new RecordingUnavailableError(
|
|
237
|
+
"session cannot run commands (no exec/execCommand) — recording finalize unavailable",
|
|
238
|
+
);
|
|
232
239
|
}
|
|
233
|
-
// F8: size-gate on the box before
|
|
234
|
-
const sizeOut = (
|
|
235
|
-
|
|
240
|
+
// F8: size-gate on the box before authorizing the upload.
|
|
241
|
+
const sizeOut = (
|
|
242
|
+
await run(
|
|
243
|
+
s,
|
|
244
|
+
`bash -lc ${shq(`stat -c %s ${proc.boxPath} 2>/dev/null || echo MISSING`)}`,
|
|
245
|
+
proc.runAs,
|
|
246
|
+
)
|
|
247
|
+
).trim();
|
|
248
|
+
const sizeLine =
|
|
249
|
+
sizeOut
|
|
250
|
+
.split("\n")
|
|
251
|
+
.map((l) => l.trim())
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.pop() ?? "MISSING";
|
|
236
254
|
if (sizeLine === "MISSING" || sizeLine === "") {
|
|
237
255
|
throw new RecordingError(`recording file missing on box: ${proc.boxPath}`, "box-death");
|
|
238
256
|
}
|
|
@@ -243,44 +261,82 @@ export async function readRecordingBytes(session: unknown, proc: RecordingProces
|
|
|
243
261
|
if (size > maxBytes) {
|
|
244
262
|
throw new RecordingError(`recording ${size}B exceeds max ${maxBytes}B`, "max-bytes-exceeded");
|
|
245
263
|
}
|
|
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
264
|
return {
|
|
266
|
-
bytes,
|
|
267
265
|
contentType: contentTypeForCodec(proc.codec),
|
|
268
|
-
sizeBytes:
|
|
266
|
+
sizeBytes: size,
|
|
269
267
|
durationSeconds: Math.max(0, Math.round((Date.now() - proc.startedAt) / 1000)),
|
|
270
268
|
};
|
|
271
269
|
}
|
|
272
270
|
|
|
271
|
+
const RECORDING_UPLOAD_OK = "OPENGENI_RECORDING_UPLOAD_OK";
|
|
272
|
+
const RECORDING_UPLOAD_MAX_SECONDS = 300;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Upload the finalized artifact directly from the sandbox to a scoped object-
|
|
276
|
+
* storage URL. The worker carries only the short URL/headers and metadata; it
|
|
277
|
+
* never materializes a base64 string or Uint8Array for a recording that may be
|
|
278
|
+
* hundreds of megabytes. curl's success marker makes an exec transport that
|
|
279
|
+
* returns non-zero command output instead of throwing still fail closed.
|
|
280
|
+
*/
|
|
281
|
+
export async function uploadRecordingArtifact(
|
|
282
|
+
session: unknown,
|
|
283
|
+
proc: RecordingProcess,
|
|
284
|
+
input: {
|
|
285
|
+
url: string;
|
|
286
|
+
requiredHeaders: Record<string, string>;
|
|
287
|
+
maxBytes?: number;
|
|
288
|
+
maxSeconds?: number;
|
|
289
|
+
},
|
|
290
|
+
): Promise<RecordingArtifactMetadata> {
|
|
291
|
+
const maxSeconds = input.maxSeconds ?? RECORDING_UPLOAD_MAX_SECONDS;
|
|
292
|
+
if (!Number.isSafeInteger(maxSeconds) || maxSeconds <= 0 || maxSeconds > 300) {
|
|
293
|
+
throw new Error("recording upload maxSeconds must be an integer between 1 and 300");
|
|
294
|
+
}
|
|
295
|
+
const metadata = await inspectRecordingArtifact(session, proc, input.maxBytes);
|
|
296
|
+
const headers = Object.entries(input.requiredHeaders)
|
|
297
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
298
|
+
.map(([name, value]) => `--header ${shq(`${name}: ${value}`)}`)
|
|
299
|
+
.join(" ");
|
|
300
|
+
const upload = [
|
|
301
|
+
"set -euo pipefail",
|
|
302
|
+
`curl --fail --silent --show-error --connect-timeout 20 --max-time ${maxSeconds} --request PUT ${headers} --upload-file ${shq(proc.boxPath)} ${shq(input.url)}`,
|
|
303
|
+
`printf '${RECORDING_UPLOAD_OK}\\n'`,
|
|
304
|
+
].join("; ");
|
|
305
|
+
const output = stripExecBanner(
|
|
306
|
+
await run(
|
|
307
|
+
session as RecordingSession,
|
|
308
|
+
`bash -lc ${shq(upload)}`,
|
|
309
|
+
proc.runAs,
|
|
310
|
+
(maxSeconds + 10) * 1_000,
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
if (!output.includes(RECORDING_UPLOAD_OK)) {
|
|
314
|
+
throw new Error("recording upload did not confirm success");
|
|
315
|
+
}
|
|
316
|
+
return metadata;
|
|
317
|
+
}
|
|
318
|
+
|
|
273
319
|
/**
|
|
274
320
|
* Delete the box artifacts. F9: call this ONLY after the storage PUT confirmed
|
|
275
321
|
* and the `available` row committed — never before. Best-effort; never throws.
|
|
276
322
|
*/
|
|
277
|
-
export async function deleteRecordingArtifacts(
|
|
323
|
+
export async function deleteRecordingArtifacts(
|
|
324
|
+
session: unknown,
|
|
325
|
+
proc: RecordingProcess,
|
|
326
|
+
): Promise<void> {
|
|
278
327
|
const s = session as RecordingSession;
|
|
279
328
|
const logFile = proc.boxPath.replace(/\.(mp4|webm)$/, ".log");
|
|
280
|
-
await run(s, `rm -f ${proc.boxPath} ${proc.pidFile} ${logFile}`, proc.runAs).catch(
|
|
329
|
+
await run(s, `rm -f ${proc.boxPath} ${proc.pidFile} ${logFile}`, proc.runAs).catch(
|
|
330
|
+
() => undefined,
|
|
331
|
+
);
|
|
281
332
|
}
|
|
282
333
|
|
|
283
334
|
/** The storage object key for a recording artifact (parallels the file-asset layout). */
|
|
284
|
-
export function recordingStorageKey(
|
|
335
|
+
export function recordingStorageKey(
|
|
336
|
+
workspaceId: string,
|
|
337
|
+
sessionId: string,
|
|
338
|
+
recordingId: string,
|
|
339
|
+
codec: RecordingCodec,
|
|
340
|
+
): string {
|
|
285
341
|
return `recordings/${workspaceId}/${sessionId}/${recordingId}.${extForCodec(codec)}`;
|
|
286
342
|
}
|