@opengeni/runtime 0.2.3 → 0.3.1
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-KNW7AMQB.js → chunk-HGQ252FL.js} +251 -22
- package/dist/chunk-HGQ252FL.js.map +1 -0
- package/dist/index-CSGkld-v.d.ts +1801 -0
- package/dist/index.d.ts +23 -3
- package/dist/index.js +238 -39
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +4 -1738
- package/dist/sandbox/index.js +11 -1
- package/package.json +3 -3
- package/src/history-sanitizer.ts +35 -38
- package/src/index.ts +133 -10
- package/src/metrics.ts +5 -0
- package/src/sandbox/display-stack.ts +69 -13
- package/src/sandbox/index.ts +100 -13
- package/src/sandbox/providers/modal.ts +225 -0
- package/src/sandbox/routing/routing-session.ts +2 -2
- package/src/sandbox/selfhosted/session.ts +21 -5
- package/src/sandbox-computer.ts +214 -48
- package/src/screenshot-error-card.ts +25 -0
- package/dist/chunk-KNW7AMQB.js.map +0 -1
package/src/sandbox/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
import { PROVIDER_REGISTRY } from "./providers";
|
|
30
30
|
import { SandboxConfigError } from "./errors";
|
|
31
31
|
import { isSelfhostedProviderNotFoundError } from "./selfhosted/session";
|
|
32
|
+
import type { RuntimeMetricsHooks } from "../metrics";
|
|
32
33
|
|
|
33
34
|
// Re-export the config-owned environment/port helpers from the leaf so the
|
|
34
35
|
// API-direct control plane can pull its full sandbox-construction surface from
|
|
@@ -53,6 +54,16 @@ export {
|
|
|
53
54
|
type ProviderRegistration,
|
|
54
55
|
type ProviderConstructionContext,
|
|
55
56
|
} from "./providers";
|
|
57
|
+
export {
|
|
58
|
+
modalSandboxAttributionEnvironment,
|
|
59
|
+
modalSandboxAttributionTags,
|
|
60
|
+
sweepModalOrphanSandboxes,
|
|
61
|
+
tagModalSandbox,
|
|
62
|
+
terminateModalSandboxById,
|
|
63
|
+
type LiveModalSandboxLeaseAttribution,
|
|
64
|
+
type ModalOrphanSweepResult,
|
|
65
|
+
type ModalSandboxAttribution,
|
|
66
|
+
} from "./providers/modal";
|
|
56
67
|
export {
|
|
57
68
|
selectBackend,
|
|
58
69
|
backendSupportsOs,
|
|
@@ -540,6 +551,8 @@ export type EstablishedSandboxSession = {
|
|
|
540
551
|
backendId: string;
|
|
541
552
|
};
|
|
542
553
|
|
|
554
|
+
export type SandboxCreatedCallback = (established: EstablishedSandboxSession) => Promise<void>;
|
|
555
|
+
|
|
543
556
|
// The structural slice we need from a provider SandboxClient to resume by id and
|
|
544
557
|
// cold-restore. Narrowed (not the full agent-loop SandboxClient) so the leaf
|
|
545
558
|
// stays agent-loop-free.
|
|
@@ -616,6 +629,16 @@ function readInstanceId(session: unknown): string {
|
|
|
616
629
|
return typeof candidate === "string" && candidate.length > 0 ? candidate : "";
|
|
617
630
|
}
|
|
618
631
|
|
|
632
|
+
async function terminateCreatedSandbox(client: ResumeCapableClient, session: unknown, sessionState: unknown): Promise<void> {
|
|
633
|
+
const clientWithDelete = client as { delete?: (state: unknown) => Promise<unknown> };
|
|
634
|
+
if (typeof clientWithDelete.delete === "function" && sessionState !== undefined) {
|
|
635
|
+
try { await clientWithDelete.delete(sessionState); } catch { /* best-effort */ }
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const sess = session as { close?: () => Promise<unknown>; terminate?: () => Promise<unknown>; kill?: () => Promise<unknown> };
|
|
639
|
+
try { await (sess.terminate ?? sess.kill ?? sess.close)?.(); } catch { /* best-effort */ }
|
|
640
|
+
}
|
|
641
|
+
|
|
619
642
|
/**
|
|
620
643
|
* Resume the one box by id from its recovery envelope, or cold-restore from the
|
|
621
644
|
* snapshot when the provider reports it gone. The envelope is the lease's
|
|
@@ -633,7 +656,13 @@ function readInstanceId(session: unknown): string {
|
|
|
633
656
|
export async function establishSandboxSessionFromEnvelope(
|
|
634
657
|
settings: Settings,
|
|
635
658
|
envelope: Record<string, unknown> | null,
|
|
636
|
-
opts: {
|
|
659
|
+
opts: {
|
|
660
|
+
sessionId: string;
|
|
661
|
+
backendOverride?: SandboxBackend;
|
|
662
|
+
environment?: Record<string, string>;
|
|
663
|
+
onSandboxCreated?: SandboxCreatedCallback;
|
|
664
|
+
metrics?: RuntimeMetricsHooks;
|
|
665
|
+
},
|
|
637
666
|
): Promise<EstablishedSandboxSession> {
|
|
638
667
|
const envelopeBackend = typeof envelope?.backendId === "string" ? (envelope.backendId as SandboxBackend) : undefined;
|
|
639
668
|
const backend = (opts.backendOverride ?? envelopeBackend ?? (settings.sandboxBackend as SandboxBackend));
|
|
@@ -679,7 +708,31 @@ export async function establishSandboxSessionFromEnvelope(
|
|
|
679
708
|
// SOLE archive-replay seam, shared by the NotFound warm-reattach path AND the
|
|
680
709
|
// cold-restore branch (b) below.
|
|
681
710
|
const coldRestore = async (resumeFallbackState?: unknown): Promise<EstablishedSandboxSession> => {
|
|
682
|
-
const
|
|
711
|
+
const createStarted = Date.now();
|
|
712
|
+
let restored: Awaited<ReturnType<NonNullable<typeof client.create>>>;
|
|
713
|
+
try {
|
|
714
|
+
restored = await client.create!({ manifest: createManifest });
|
|
715
|
+
recordSandboxCreateMetric(opts.metrics, client.backendId, "completed", createStarted);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
recordSandboxCreateMetric(opts.metrics, client.backendId, "failed", createStarted);
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
let restoredState = (restored as { state?: unknown }).state;
|
|
721
|
+
let established: EstablishedSandboxSession = {
|
|
722
|
+
client,
|
|
723
|
+
session: restored,
|
|
724
|
+
sessionState: restoredState ?? resumeFallbackState,
|
|
725
|
+
instanceId: readInstanceId(restored),
|
|
726
|
+
backendId: client.backendId,
|
|
727
|
+
};
|
|
728
|
+
if (opts.onSandboxCreated) {
|
|
729
|
+
try {
|
|
730
|
+
await opts.onSandboxCreated(established);
|
|
731
|
+
} catch (createCallbackError) {
|
|
732
|
+
await terminateCreatedSandbox(client, restored, restoredState);
|
|
733
|
+
throw createCallbackError;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
683
736
|
if (workspaceArchive) {
|
|
684
737
|
const hydrate = (restored as { hydrateWorkspace?: (data: Uint8Array) => Promise<void> }).hydrateWorkspace;
|
|
685
738
|
if (typeof hydrate === "function") {
|
|
@@ -696,21 +749,38 @@ export async function establishSandboxSessionFromEnvelope(
|
|
|
696
749
|
// re-throwing so no box leaks. The original error semantics are preserved
|
|
697
750
|
// (the re-throw propagates to the caller). This mirrors the reaper's
|
|
698
751
|
// discipline: NEVER leave an orphaned box running.
|
|
699
|
-
|
|
700
|
-
const clientWithDelete = client as { delete?: (state: unknown) => Promise<unknown> };
|
|
701
|
-
if (typeof clientWithDelete.delete === "function" && restoredState !== undefined) {
|
|
702
|
-
try { await clientWithDelete.delete(restoredState); } catch { /* best-effort; re-throw the hydrate error below */ }
|
|
703
|
-
} else {
|
|
704
|
-
// No delete() — try a session-level close/terminate as a fallback.
|
|
705
|
-
const sess = restored as { close?: () => Promise<unknown>; terminate?: () => Promise<unknown> };
|
|
706
|
-
try { await (sess.terminate ?? sess.close)?.(); } catch { /* best-effort */ }
|
|
707
|
-
}
|
|
752
|
+
await terminateCreatedSandbox(client, restored, restoredState);
|
|
708
753
|
throw hydrateError;
|
|
709
754
|
}
|
|
755
|
+
const hydratedState = (restored as { state?: unknown }).state;
|
|
756
|
+
const hydratedInstanceId = readInstanceId(restored);
|
|
757
|
+
if (hydratedInstanceId && hydratedInstanceId !== established.instanceId) {
|
|
758
|
+
established = {
|
|
759
|
+
client,
|
|
760
|
+
session: restored,
|
|
761
|
+
sessionState: hydratedState ?? resumeFallbackState,
|
|
762
|
+
instanceId: hydratedInstanceId,
|
|
763
|
+
backendId: client.backendId,
|
|
764
|
+
};
|
|
765
|
+
if (opts.onSandboxCreated) {
|
|
766
|
+
try {
|
|
767
|
+
await opts.onSandboxCreated(established);
|
|
768
|
+
} catch (createCallbackError) {
|
|
769
|
+
await terminateCreatedSandbox(client, restored, hydratedState);
|
|
770
|
+
throw createCallbackError;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
710
774
|
}
|
|
711
775
|
}
|
|
712
|
-
|
|
713
|
-
return {
|
|
776
|
+
restoredState = (restored as { state?: unknown }).state;
|
|
777
|
+
return {
|
|
778
|
+
client,
|
|
779
|
+
session: restored,
|
|
780
|
+
sessionState: restoredState ?? resumeFallbackState,
|
|
781
|
+
instanceId: readInstanceId(restored),
|
|
782
|
+
backendId: client.backendId,
|
|
783
|
+
};
|
|
714
784
|
};
|
|
715
785
|
|
|
716
786
|
// Does the envelope carry a RESUMABLE box id (warm reattach), or only a
|
|
@@ -770,6 +840,23 @@ export async function establishSandboxSessionFromEnvelope(
|
|
|
770
840
|
return await coldRestore();
|
|
771
841
|
}
|
|
772
842
|
|
|
843
|
+
function recordSandboxCreateMetric(
|
|
844
|
+
metrics: RuntimeMetricsHooks | undefined,
|
|
845
|
+
backend: string,
|
|
846
|
+
outcome: "completed" | "failed",
|
|
847
|
+
startedMs: number,
|
|
848
|
+
): void {
|
|
849
|
+
try {
|
|
850
|
+
metrics?.onSandboxCreate?.({
|
|
851
|
+
backend,
|
|
852
|
+
outcome,
|
|
853
|
+
durationSeconds: Math.max(0, (Date.now() - startedMs) / 1000),
|
|
854
|
+
});
|
|
855
|
+
} catch {
|
|
856
|
+
// Metrics emission must not affect sandbox lifecycle.
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
773
860
|
// A client that can SERIALIZE a live session state back to the persistable
|
|
774
861
|
// envelope form (the inverse of deserializeSessionState). Narrowed so the leaf
|
|
775
862
|
// stays agent-loop-free.
|
|
@@ -1,9 +1,53 @@
|
|
|
1
1
|
import { ModalImageSelector, ModalSandboxClient } from "@openai/agents-extensions/sandbox/modal";
|
|
2
2
|
import { effectiveModalIdleTimeoutSeconds } from "@opengeni/config";
|
|
3
|
+
import type { Settings } from "@opengeni/config";
|
|
3
4
|
import { CAPABILITY_DESCRIPTORS } from "../capabilities";
|
|
4
5
|
import { SandboxConfigError } from "../errors";
|
|
5
6
|
import type { ProviderRegistration } from "./types";
|
|
6
7
|
|
|
8
|
+
const MODAL_ORPHAN_SWEEP_LIMIT = 50;
|
|
9
|
+
const MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS = 30 * 60_000;
|
|
10
|
+
|
|
11
|
+
export type ModalSandboxAttribution = {
|
|
12
|
+
leaseId: string;
|
|
13
|
+
workspaceId: string;
|
|
14
|
+
sandboxGroupId: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type LiveModalSandboxLeaseAttribution = ModalSandboxAttribution & {
|
|
18
|
+
instanceId: string | null;
|
|
19
|
+
liveness?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ModalOrphanSweepTermination = {
|
|
23
|
+
sandboxId: string;
|
|
24
|
+
reason: "stale_attribution" | "unattributed";
|
|
25
|
+
tags: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ModalOrphanSweepResult = {
|
|
29
|
+
examined: number;
|
|
30
|
+
terminated: ModalOrphanSweepTermination[];
|
|
31
|
+
skipped: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function modalSandboxAttributionEnvironment(input: ModalSandboxAttribution): Record<string, string> {
|
|
35
|
+
return {
|
|
36
|
+
OPENGENI_SANDBOX_LEASE_ID: input.leaseId,
|
|
37
|
+
OPENGENI_SANDBOX_GROUP_ID: input.sandboxGroupId,
|
|
38
|
+
OPENGENI_WORKSPACE_ID: input.workspaceId,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function modalSandboxAttributionTags(input: ModalSandboxAttribution): Record<string, string> {
|
|
43
|
+
return {
|
|
44
|
+
opengeni: "true",
|
|
45
|
+
opengeni_lease_id: input.leaseId,
|
|
46
|
+
opengeni_workspace_id: input.workspaceId,
|
|
47
|
+
opengeni_sandbox_group_id: input.sandboxGroupId,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
7
51
|
export const modalProvider: ProviderRegistration = {
|
|
8
52
|
backend: "modal",
|
|
9
53
|
descriptor: CAPABILITY_DESCRIPTORS.modal,
|
|
@@ -23,8 +67,13 @@ export const modalProvider: ProviderRegistration = {
|
|
|
23
67
|
const options: NonNullable<ConstructorParameters<typeof ModalSandboxClient>[0]> = {
|
|
24
68
|
appName: settings.modalAppName,
|
|
25
69
|
timeoutMs: settings.modalTimeoutSeconds * 1000,
|
|
70
|
+
sandboxCreateTimeoutS: Math.ceil(settings.sandboxWarmingTimeoutMs / 1000),
|
|
26
71
|
exposedPorts,
|
|
27
72
|
env: environment,
|
|
73
|
+
// The Modal JS SDK's sandbox default command already sleeps until timeout
|
|
74
|
+
// or explicit termination. Do not let the Agents extension stamp a separate
|
|
75
|
+
// hardcoded sleep command; OPENGENI_MODAL_TIMEOUT_SECONDS owns lifetime.
|
|
76
|
+
useSleepCmd: false,
|
|
28
77
|
};
|
|
29
78
|
// gap-fill (module 03 §4.1): these SDK options were previously unmapped.
|
|
30
79
|
// ALWAYS pin idleTimeoutMs (sandbox-file-persistence): an UNSET idle timeout
|
|
@@ -53,3 +102,179 @@ export const modalProvider: ProviderRegistration = {
|
|
|
53
102
|
return new ModalSandboxClient(options);
|
|
54
103
|
},
|
|
55
104
|
};
|
|
105
|
+
|
|
106
|
+
type ModalModule = typeof import("modal");
|
|
107
|
+
type ModalClientLike = InstanceType<ModalModule["ModalClient"]>;
|
|
108
|
+
|
|
109
|
+
function modalClientOptions(settings: Settings): ConstructorParameters<ModalModule["ModalClient"]>[0] {
|
|
110
|
+
return {
|
|
111
|
+
...(settings.modalTokenId ? { tokenId: settings.modalTokenId } : {}),
|
|
112
|
+
...(settings.modalTokenSecret ? { tokenSecret: settings.modalTokenSecret } : {}),
|
|
113
|
+
...(settings.modalEnvironment ? { environment: settings.modalEnvironment } : {}),
|
|
114
|
+
...(settings.modalTimeoutSeconds ? { timeoutMs: settings.modalTimeoutSeconds * 1000 } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function createModalClient(settings: Settings): Promise<ModalClientLike> {
|
|
119
|
+
const modal = await import("modal");
|
|
120
|
+
return new modal.ModalClient(modalClientOptions(settings));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function tagModalSandbox(
|
|
124
|
+
settings: Settings,
|
|
125
|
+
sandboxId: string,
|
|
126
|
+
attribution: ModalSandboxAttribution,
|
|
127
|
+
): Promise<boolean> {
|
|
128
|
+
if (!sandboxId) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const modal = await createModalClient(settings);
|
|
132
|
+
try {
|
|
133
|
+
const sandbox = await modal.sandboxes.fromId(sandboxId);
|
|
134
|
+
await sandbox.setTags(modalSandboxAttributionTags(attribution));
|
|
135
|
+
return true;
|
|
136
|
+
} finally {
|
|
137
|
+
modal.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function terminateModalSandboxById(settings: Settings, sandboxId: string): Promise<boolean> {
|
|
142
|
+
if (!sandboxId) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const modal = await createModalClient(settings);
|
|
146
|
+
try {
|
|
147
|
+
const sandbox = await modal.sandboxes.fromId(sandboxId);
|
|
148
|
+
await sandbox.terminate();
|
|
149
|
+
return true;
|
|
150
|
+
} finally {
|
|
151
|
+
modal.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type ModalSandboxInfo = {
|
|
156
|
+
id: string;
|
|
157
|
+
createdAt?: number;
|
|
158
|
+
tags?: Array<{ tagName?: string; tagValue?: string }>;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
type ModalCpListClient = ModalClientLike & {
|
|
162
|
+
cpClient: {
|
|
163
|
+
sandboxList(input: {
|
|
164
|
+
appId?: string;
|
|
165
|
+
beforeTimestamp?: number;
|
|
166
|
+
environmentName?: string;
|
|
167
|
+
includeFinished?: boolean;
|
|
168
|
+
tags?: Array<{ tagName: string; tagValue: string }>;
|
|
169
|
+
}): Promise<{ sandboxes?: ModalSandboxInfo[] }>;
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
function tagsFromInfo(info: ModalSandboxInfo): Record<string, string> {
|
|
174
|
+
const tags: Record<string, string> = {};
|
|
175
|
+
for (const tag of info.tags ?? []) {
|
|
176
|
+
if (typeof tag.tagName === "string" && typeof tag.tagValue === "string") {
|
|
177
|
+
tags[tag.tagName] = tag.tagValue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return tags;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sandboxCreatedAtMs(info: ModalSandboxInfo): number | null {
|
|
184
|
+
if (typeof info.createdAt !== "number" || !Number.isFinite(info.createdAt) || info.createdAt <= 0) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
// Modal protobuf timestamps in this SDK are seconds as doubles.
|
|
188
|
+
return info.createdAt < 10_000_000_000 ? Math.floor(info.createdAt * 1000) : Math.floor(info.createdAt);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function attributionKey(input: Pick<ModalSandboxAttribution, "leaseId" | "workspaceId" | "sandboxGroupId">): string {
|
|
192
|
+
return `${input.workspaceId}:${input.sandboxGroupId}:${input.leaseId}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function sweepModalOrphanSandboxes(
|
|
196
|
+
settings: Settings,
|
|
197
|
+
liveLeases: LiveModalSandboxLeaseAttribution[],
|
|
198
|
+
options: {
|
|
199
|
+
now?: Date;
|
|
200
|
+
maxTerminations?: number;
|
|
201
|
+
unattributedGraceMs?: number;
|
|
202
|
+
client?: ModalClientLike;
|
|
203
|
+
} = {},
|
|
204
|
+
): Promise<ModalOrphanSweepResult> {
|
|
205
|
+
const nowMs = options.now?.getTime() ?? Date.now();
|
|
206
|
+
const maxTerminations = options.maxTerminations ?? MODAL_ORPHAN_SWEEP_LIMIT;
|
|
207
|
+
const unattributedGraceMs = options.unattributedGraceMs ?? MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS;
|
|
208
|
+
const liveByAttribution = new Map(liveLeases.map((lease) => [attributionKey(lease), lease]));
|
|
209
|
+
const ownedClient = options.client ? null : await createModalClient(settings);
|
|
210
|
+
const modal = (options.client ?? ownedClient)! as ModalCpListClient;
|
|
211
|
+
try {
|
|
212
|
+
const app = await modal.apps.fromName(settings.modalAppName, {
|
|
213
|
+
createIfMissing: false,
|
|
214
|
+
...(settings.modalEnvironment ? { environment: settings.modalEnvironment } : {}),
|
|
215
|
+
});
|
|
216
|
+
const appId = app.appId;
|
|
217
|
+
if (!appId) {
|
|
218
|
+
return { examined: 0, terminated: [], skipped: 0 };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let examined = 0;
|
|
222
|
+
let skipped = 0;
|
|
223
|
+
const terminated: ModalOrphanSweepTermination[] = [];
|
|
224
|
+
let beforeTimestamp: number | undefined;
|
|
225
|
+
while (terminated.length < maxTerminations) {
|
|
226
|
+
const response = await modal.cpClient.sandboxList({
|
|
227
|
+
appId,
|
|
228
|
+
...(beforeTimestamp !== undefined ? { beforeTimestamp } : {}),
|
|
229
|
+
includeFinished: false,
|
|
230
|
+
...(settings.modalEnvironment ? { environmentName: settings.modalEnvironment } : {}),
|
|
231
|
+
tags: [],
|
|
232
|
+
});
|
|
233
|
+
const sandboxes = response.sandboxes ?? [];
|
|
234
|
+
if (sandboxes.length === 0) {
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
for (const info of sandboxes) {
|
|
238
|
+
examined += 1;
|
|
239
|
+
const tags = tagsFromInfo(info);
|
|
240
|
+
const leaseId = tags.opengeni_lease_id;
|
|
241
|
+
const workspaceId = tags.opengeni_workspace_id;
|
|
242
|
+
const sandboxGroupId = tags.opengeni_sandbox_group_id;
|
|
243
|
+
let reason: ModalOrphanSweepTermination["reason"] | null = null;
|
|
244
|
+
if (leaseId && workspaceId && sandboxGroupId) {
|
|
245
|
+
const live = liveByAttribution.get(attributionKey({ leaseId, workspaceId, sandboxGroupId }));
|
|
246
|
+
if (!live || (live.instanceId && live.instanceId !== info.id)) {
|
|
247
|
+
reason = "stale_attribution";
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
const createdAtMs = sandboxCreatedAtMs(info);
|
|
251
|
+
if (createdAtMs !== null && nowMs - createdAtMs >= unattributedGraceMs) {
|
|
252
|
+
reason = "unattributed";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!reason) {
|
|
257
|
+
skipped += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const sandbox = await modal.sandboxes.fromId(info.id);
|
|
262
|
+
await sandbox.terminate();
|
|
263
|
+
terminated.push({ sandboxId: info.id, reason, tags });
|
|
264
|
+
} catch {
|
|
265
|
+
skipped += 1;
|
|
266
|
+
}
|
|
267
|
+
if (terminated.length >= maxTerminations) {
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
beforeTimestamp = sandboxes[sandboxes.length - 1]?.createdAt;
|
|
272
|
+
if (beforeTimestamp === undefined) {
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { examined, terminated, skipped };
|
|
277
|
+
} finally {
|
|
278
|
+
ownedClient?.close();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -74,7 +74,7 @@ export interface RoutableBackendSession {
|
|
|
74
74
|
// `event` is kept `unknown` (mirroring the interface's structural style + avoiding
|
|
75
75
|
// a proto import into the leaf); the SelfhostedSession takes `DesktopInputRequest["event"]`.
|
|
76
76
|
desktopInput?(event: unknown): Promise<void>;
|
|
77
|
-
screenshot?(): Promise<{ png: Uint8Array; width: number; height: number }>;
|
|
77
|
+
screenshot?(): Promise<{ png: Uint8Array; width: number; height: number; nativeWidth: number; nativeHeight: number }>;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** The resolved active backend for an epoch: the live session + the sandbox id it
|
|
@@ -189,7 +189,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
189
189
|
// that cannot serve them. So the constructor assigns them ONLY when the
|
|
190
190
|
// construction-time default backend actually implements the native surface (below).
|
|
191
191
|
desktopInput?: (event: unknown) => Promise<void>;
|
|
192
|
-
screenshot?: () => Promise<{ png: Uint8Array; width: number; height: number }>;
|
|
192
|
+
screenshot?: () => Promise<{ png: Uint8Array; width: number; height: number; nativeWidth: number; nativeHeight: number }>;
|
|
193
193
|
|
|
194
194
|
constructor(deps: RoutingSandboxSessionDeps) {
|
|
195
195
|
this.deps = deps;
|
|
@@ -545,16 +545,32 @@ export class SelfhostedSession {
|
|
|
545
545
|
/** Computer-use VIEW op: capture a single PNG screenshot of the machine's desktop
|
|
546
546
|
* plus its geometry (via ScreenCaptureKit / x11). NOT consent-gated (a view op —
|
|
547
547
|
* the view/control decoupling), so it works with a display but no screen-control
|
|
548
|
-
* consent. Returns the raw encoded bytes + width/height
|
|
549
|
-
|
|
548
|
+
* consent. Returns the raw encoded bytes + the ENCODED width/height, plus the
|
|
549
|
+
* NATIVE (pre-downscale) geometry: when the agent had to downscale the PNG to fit
|
|
550
|
+
* the transport's max payload, `nativeWidth`/`nativeHeight` carry the original
|
|
551
|
+
* capture size so the computer-use layer can scale model clicks (in encoded-pixel
|
|
552
|
+
* space) back to native pixels. An older agent leaves them 0 → read as "same as
|
|
553
|
+
* width/height" (no downscale). */
|
|
554
|
+
async screenshot(): Promise<{
|
|
555
|
+
png: Uint8Array;
|
|
556
|
+
width: number;
|
|
557
|
+
height: number;
|
|
558
|
+
nativeWidth: number;
|
|
559
|
+
nativeHeight: number;
|
|
560
|
+
}> {
|
|
550
561
|
const result = await this.call({ $case: "desktopScreenshot", desktopScreenshot: {} });
|
|
551
562
|
if (result.$case !== "desktopScreenshot") {
|
|
552
563
|
throw new Error(`selfhosted screenshot: unexpected result ${result.$case}`);
|
|
553
564
|
}
|
|
565
|
+
const s = result.desktopScreenshot;
|
|
566
|
+
// Back-compat: an agent predating the native-geometry fields sends 0 → treat the
|
|
567
|
+
// encoded geometry AS the native geometry (scale factor 1.0, no coordinate shift).
|
|
554
568
|
return {
|
|
555
|
-
png:
|
|
556
|
-
width:
|
|
557
|
-
height:
|
|
569
|
+
png: s.png,
|
|
570
|
+
width: s.width,
|
|
571
|
+
height: s.height,
|
|
572
|
+
nativeWidth: s.nativeWidth || s.width,
|
|
573
|
+
nativeHeight: s.nativeHeight || s.height,
|
|
558
574
|
};
|
|
559
575
|
}
|
|
560
576
|
|