@opengeni/api-router 0.23.1 → 0.30.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/app.d.ts +3 -2
- package/dist/app.js +5 -3
- package/dist/auth/canonical-human-session-admission.d.ts +27 -0
- package/dist/browser-auth-broker.d.ts +20 -0
- package/dist/browser-controller-authority.d.ts +61 -0
- package/dist/browser-network-route.d.ts +8 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-T4T2PGU4.js → chunk-2PQMSRCB.js} +37891 -17463
- package/dist/chunk-2PQMSRCB.js.map +1 -0
- package/dist/codemode.d.ts +23 -0
- package/dist/controller-data-plane.d.ts +12 -0
- package/dist/editable-artifact-live-hints.d.ts +11 -0
- package/dist/editable-artifact-native-kernel.d.ts +37 -0
- package/dist/editable-artifact-office-import.d.ts +22 -0
- package/dist/editable-artifact-production.d.ts +29 -0
- package/dist/editable-artifact-websocket.d.ts +49 -0
- package/dist/editable-artifact-workspace-files.d.ts +23 -0
- package/dist/github-browser-flow.d.ts +6 -0
- package/dist/http/auth.d.ts +1 -1
- package/dist/http/cors.d.ts +1 -0
- package/dist/http/sse.d.ts +18 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1796 -22
- package/dist/index.js.map +1 -1
- package/dist/integrations/api-integrations.d.ts +24 -0
- package/dist/integrations/atlassian.d.ts +176 -0
- package/dist/integrations/fiken.d.ts +233 -0
- package/dist/integrations/github-skill-source.d.ts +5 -0
- package/dist/integrations/google-drive.d.ts +85 -0
- package/dist/integrations/oauth-client.d.ts +22 -1
- package/dist/integrations/provider-oauth.d.ts +17 -0
- package/dist/integrations/slack-bot.d.ts +175 -1
- package/dist/integrations/slack-interactions.d.ts +24 -6
- package/dist/integrations/social-api.d.ts +2 -1
- package/dist/interaction-holder-heartbeat.d.ts +17 -0
- package/dist/interaction-metrics.d.ts +11 -0
- package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
- package/dist/mcp/editable-artifacts.d.ts +13 -0
- package/dist/mcp/scheduled-task-view.d.ts +168 -0
- package/dist/mcp/server.d.ts +16 -4
- package/dist/memory-slack-delivery.d.ts +9 -0
- package/dist/model-catalog.d.ts +1 -0
- package/dist/routes/api-integrations.d.ts +8 -0
- package/dist/routes/browser-identities.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +6 -0
- package/dist/routes/canonical-human-identities.d.ts +3 -0
- package/dist/routes/channels.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +3 -0
- package/dist/routes/computer-sessions.d.ts +6 -0
- package/dist/routes/editable-artifacts.d.ts +44 -0
- package/dist/routes/integration-facets.d.ts +3 -0
- package/dist/routes/interaction-resources.d.ts +5 -0
- package/dist/routes/memory-slack-publications.d.ts +6 -0
- package/dist/routes/organization-memberships.d.ts +3 -0
- package/dist/routes/plugins.d.ts +8 -0
- package/dist/routes/sessions.d.ts +18 -2
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/slack-task-policy.d.ts +3 -0
- package/dist/routes/supergrok.d.ts +3 -0
- package/dist/routes/video-generation.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +62 -2
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +6 -1
- package/dist/sandbox/viewer.d.ts +7 -3
- package/dist/slack-reaction-files.d.ts +27 -0
- package/dist/temporal-schedule-cleanup.d.ts +26 -0
- package/dist/transcription/providers/xai-subscription.d.ts +8 -0
- package/dist/xai-realtime.d.ts +26 -0
- package/dist/xai-subscription-auth.d.ts +23 -0
- package/package.json +20 -14
- package/src/app.ts +604 -64
- package/src/auth/canonical-human-session-admission.ts +116 -0
- package/src/auth/managed-auth.ts +101 -0
- package/src/browser-auth-broker.ts +155 -0
- package/src/browser-controller-authority.ts +186 -0
- package/src/browser-network-route.ts +34 -0
- package/src/browser-state-authority.ts +236 -0
- package/src/codemode.ts +186 -0
- package/src/controller-data-plane.ts +35 -0
- package/src/editable-artifact-live-hints.ts +64 -0
- package/src/editable-artifact-native-kernel.ts +659 -0
- package/src/editable-artifact-office-import.ts +230 -0
- package/src/editable-artifact-production.ts +419 -0
- package/src/editable-artifact-websocket.ts +311 -0
- package/src/editable-artifact-workspace-files.ts +186 -0
- package/src/github-browser-flow.ts +35 -6
- package/src/http/auth.ts +29 -9
- package/src/http/cors.ts +3 -0
- package/src/http/sse.ts +229 -5
- package/src/index.ts +119 -20
- package/src/integrations/api-integrations.ts +365 -0
- package/src/integrations/atlassian.ts +1621 -0
- package/src/integrations/fiken.ts +1230 -0
- package/src/integrations/github-skill-source.ts +142 -0
- package/src/integrations/google-drive.ts +1005 -64
- package/src/integrations/oauth-client.ts +123 -33
- package/src/integrations/provider-oauth.ts +756 -0
- package/src/integrations/slack-bot.ts +669 -11
- package/src/integrations/slack-interactions.ts +2063 -93
- package/src/integrations/social-api.ts +11 -0
- package/src/interaction-holder-heartbeat.ts +95 -0
- package/src/interaction-metrics.ts +159 -0
- package/src/mcp/documents.ts +146 -6
- package/src/mcp/editable-artifact-query-schema.ts +236 -0
- package/src/mcp/editable-artifacts.ts +448 -0
- package/src/mcp/scheduled-task-view.ts +34 -0
- package/src/mcp/server.ts +1756 -355
- package/src/memory-slack-delivery.ts +209 -0
- package/src/model-catalog.ts +19 -6
- package/src/routes/api-integrations.ts +410 -0
- package/src/routes/browser-identities.ts +160 -0
- package/src/routes/browser-sessions.ts +4482 -0
- package/src/routes/canonical-human-identities.ts +156 -0
- package/src/routes/channels.ts +90 -0
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1602 -0
- package/src/routes/connections.ts +421 -99
- package/src/routes/documents.ts +22 -3
- package/src/routes/editable-artifacts.ts +1159 -0
- package/src/routes/files.ts +106 -4
- package/src/routes/github.ts +18 -2
- package/src/routes/install.ts +7 -1
- package/src/routes/integration-facets.ts +258 -0
- package/src/routes/interaction-resources.ts +595 -0
- package/src/routes/memory-slack-publications.ts +216 -0
- package/src/routes/organization-memberships.ts +53 -0
- package/src/routes/packs.ts +437 -7
- package/src/routes/plugins.ts +752 -0
- package/src/routes/rigs.ts +77 -20
- package/src/routes/scheduled-tasks.ts +67 -40
- package/src/routes/sessions.ts +830 -278
- package/src/routes/skills.ts +256 -0
- package/src/routes/slack-task-policy.ts +115 -0
- package/src/routes/supergrok.ts +717 -0
- package/src/routes/transcription-recordings.ts +9 -2
- package/src/routes/transcriptions.ts +2 -1
- package/src/routes/video-generation.ts +162 -0
- package/src/routes/workspaces.ts +68 -28
- package/src/sandbox/channel-a.ts +750 -116
- package/src/sandbox/machines.ts +5 -5
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +39 -12
- package/src/slack-reaction-files.ts +181 -0
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/src/transcription/providers/xai-subscription.ts +110 -0
- package/src/transcription/service.ts +17 -2
- package/src/xai-realtime.ts +216 -0
- package/src/xai-subscription-auth.ts +132 -0
- package/dist/chunk-T4T2PGU4.js.map +0 -1
- package/dist/mcp/toolspace.d.ts +0 -71
- package/src/mcp/toolspace.ts +0 -1190
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
applyGitAuthPointerEnvironment,
|
|
22
22
|
hasGitCredentialRepositorySelection,
|
|
23
23
|
hasGitHubRepositorySelection,
|
|
24
|
-
|
|
24
|
+
sandboxLifecycleTransitionWaitMs,
|
|
25
25
|
stableSandboxEnvironmentForRun,
|
|
26
26
|
type Settings,
|
|
27
27
|
} from "@opengeni/config";
|
|
@@ -37,11 +37,17 @@ import {
|
|
|
37
37
|
readActiveSandbox,
|
|
38
38
|
readLease,
|
|
39
39
|
releaseLeaseHolder,
|
|
40
|
+
SandboxProviderReadLockUnavailableError,
|
|
41
|
+
withSandboxProviderReadLock,
|
|
40
42
|
type Database,
|
|
41
43
|
type LeaseSnapshot,
|
|
42
44
|
} from "@opengeni/db";
|
|
43
45
|
import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
|
|
44
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
sandboxLeaseTelemetryKey,
|
|
48
|
+
sandboxOperationMetricObserver,
|
|
49
|
+
type Observability,
|
|
50
|
+
} from "@opengeni/observability";
|
|
45
51
|
import { HTTPException } from "hono/http-exception";
|
|
46
52
|
|
|
47
53
|
import {
|
|
@@ -51,13 +57,17 @@ import {
|
|
|
51
57
|
SandboxChannelAService,
|
|
52
58
|
NatsControlRpc,
|
|
53
59
|
NatsOpStreamTransport,
|
|
60
|
+
SandboxResumeIdentityMismatchError,
|
|
61
|
+
SandboxResumeIdentityUnavailableError,
|
|
54
62
|
ChannelAConflictError,
|
|
55
63
|
ChannelANotFoundError,
|
|
56
64
|
ChannelAUnsupportedError,
|
|
57
65
|
ChannelAUnavailableError,
|
|
58
66
|
ChannelAValidationError,
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
BrowserControlRequestError,
|
|
68
|
+
BrowserControlTransportError,
|
|
69
|
+
codemodeTokenFileFromEnvironment,
|
|
70
|
+
withCodemodeTokenSession,
|
|
61
71
|
withRunCredentialsSession,
|
|
62
72
|
type ChannelASession,
|
|
63
73
|
type EstablishedSandboxSession,
|
|
@@ -73,12 +83,74 @@ export type ChannelAServices = {
|
|
|
73
83
|
observability?: Observability | undefined;
|
|
74
84
|
};
|
|
75
85
|
|
|
86
|
+
export type ChannelAOperation =
|
|
87
|
+
| "fs.list"
|
|
88
|
+
| "fs.list-batch"
|
|
89
|
+
| "fs.read"
|
|
90
|
+
| "fs.write"
|
|
91
|
+
| "fs.delete"
|
|
92
|
+
| "fs.move"
|
|
93
|
+
| "fs.mkdir"
|
|
94
|
+
| "git.status"
|
|
95
|
+
| "git.diff"
|
|
96
|
+
| "git.read-batch"
|
|
97
|
+
| "git.log"
|
|
98
|
+
| "git.show"
|
|
99
|
+
| "terminal.exec"
|
|
100
|
+
| "terminal.pty.open"
|
|
101
|
+
| "terminal.pty.write"
|
|
102
|
+
| "terminal.pty.resize"
|
|
103
|
+
| "terminal.pty.close"
|
|
104
|
+
| "browser.create"
|
|
105
|
+
| "browser.resume"
|
|
106
|
+
| "browser.suspend"
|
|
107
|
+
| "browser.end"
|
|
108
|
+
| "browser.read"
|
|
109
|
+
| "browser.action"
|
|
110
|
+
| "browser.control"
|
|
111
|
+
| "browser.download.save"
|
|
112
|
+
| "browser.attach"
|
|
113
|
+
| "computer.create"
|
|
114
|
+
| "computer.end"
|
|
115
|
+
| "computer.read"
|
|
116
|
+
| "computer.action"
|
|
117
|
+
| "computer.control"
|
|
118
|
+
| "computer.attach";
|
|
119
|
+
|
|
76
120
|
export type ChannelAContext = {
|
|
77
121
|
accountId: string;
|
|
78
122
|
workspaceId: string;
|
|
79
123
|
session: Session;
|
|
80
124
|
// The principal that drives the op (for emit attribution + pty opened_by).
|
|
81
125
|
subjectId: string;
|
|
126
|
+
/** Cancel lifecycle waiting when the originating HTTP request disconnects. */
|
|
127
|
+
waitSignal?: AbortSignal | undefined;
|
|
128
|
+
/** Bounded route identity for metrics and safe operator diagnostics. */
|
|
129
|
+
operation?: ChannelAOperation | undefined;
|
|
130
|
+
/** The callback is an interaction-controller read or an exactly-once action.
|
|
131
|
+
* A controller transport failure may therefore rebuild the exact fenced
|
|
132
|
+
* provider handle and replay the request. Tab/lifecycle mutations that lack
|
|
133
|
+
* a controller operation id must never opt into this recovery. */
|
|
134
|
+
retryControllerTransport?: boolean | undefined;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export type ChannelAOperationFailureReason =
|
|
138
|
+
| "request_cancelled"
|
|
139
|
+
| "provider_read_busy"
|
|
140
|
+
| "provider_unavailable"
|
|
141
|
+
| "lifecycle_conflict"
|
|
142
|
+
| "request_rejected"
|
|
143
|
+
| "unexpected";
|
|
144
|
+
|
|
145
|
+
export type ChannelAOperationFailureDiagnostic = {
|
|
146
|
+
reason: ChannelAOperationFailureReason;
|
|
147
|
+
status: number;
|
|
148
|
+
errorCode:
|
|
149
|
+
| "sandbox_channel_a_cancelled"
|
|
150
|
+
| "sandbox_channel_a_provider_busy"
|
|
151
|
+
| "sandbox_channel_a_provider_unavailable"
|
|
152
|
+
| "sandbox_channel_a_lifecycle_conflict"
|
|
153
|
+
| "sandbox_channel_a_operation_failed";
|
|
82
154
|
};
|
|
83
155
|
|
|
84
156
|
// The live op surface handed to a route's callback: the service + the live lease
|
|
@@ -88,6 +160,10 @@ export type ChannelAHandle = {
|
|
|
88
160
|
/** Connected Machine homes deliberately have no cloud lease. Durable PTYs
|
|
89
161
|
* require a real home-provider lease and reject this null case. */
|
|
90
162
|
lease: LeaseSnapshot | null;
|
|
163
|
+
/** Exact placement-home session established under this request's lease or
|
|
164
|
+
* Connected Machine fence. Unlike routingSession, this never follows a later
|
|
165
|
+
* active-sandbox pointer and is safe for placement-bound controllers. */
|
|
166
|
+
homeSession: ChannelASession;
|
|
91
167
|
routingSession: RoutingSandboxSession;
|
|
92
168
|
requestId: string;
|
|
93
169
|
};
|
|
@@ -104,13 +180,30 @@ export type ChannelAHandle = {
|
|
|
104
180
|
* expire opportunistically; eviction only drops local references and never
|
|
105
181
|
* terminates the lease-owned sandbox.
|
|
106
182
|
*/
|
|
107
|
-
|
|
183
|
+
// Read/viewer handles and process-capable handles deliberately have separate
|
|
184
|
+
// caches. The pinned Modal patch rotates a handle's command-router transport in
|
|
185
|
+
// place, while a typed read failure below still gets one fresh-handle fallback.
|
|
186
|
+
// Periodically rebuilding a healthy hot read handle would add multi-second
|
|
187
|
+
// stalls, so both caches keep the pre-existing five-minute IDLE lifetime.
|
|
188
|
+
//
|
|
189
|
+
// Modal and Unix/Docker SDK sessions retain yielded exec/PTY process objects in
|
|
190
|
+
// a process-local map; rebuilding the wrapper cannot reconstruct those objects
|
|
191
|
+
// from the numeric provider session id. Reads therefore never enter or evict
|
|
192
|
+
// the process cache merely to refresh their own transport.
|
|
193
|
+
const CHANNEL_A_READ_HANDLE_CACHE_IDLE_TTL_MS = 5 * 60_000;
|
|
194
|
+
const CHANNEL_A_PROCESS_HANDLE_CACHE_IDLE_TTL_MS = 5 * 60_000;
|
|
108
195
|
const CHANNEL_A_HANDLE_CACHE_MAX_ENTRIES = 64;
|
|
109
|
-
type
|
|
196
|
+
type CachedReadHandle = {
|
|
110
197
|
promise: Promise<EstablishedSandboxSession>;
|
|
111
|
-
|
|
198
|
+
lastUsedAtMonotonicMs: number;
|
|
112
199
|
};
|
|
113
|
-
|
|
200
|
+
type CachedProcessHandle = {
|
|
201
|
+
promise: Promise<EstablishedSandboxSession>;
|
|
202
|
+
lastUsedAtMonotonicMs: number;
|
|
203
|
+
};
|
|
204
|
+
export type EstablishedHandleCacheKind = "read" | "process" | "none";
|
|
205
|
+
const establishedReadHandleCache = new Map<string, CachedReadHandle>();
|
|
206
|
+
const establishedProcessHandleCache = new Map<string, CachedProcessHandle>();
|
|
114
207
|
|
|
115
208
|
function establishedHandleCacheKey(
|
|
116
209
|
workspaceId: string,
|
|
@@ -120,42 +213,98 @@ function establishedHandleCacheKey(
|
|
|
120
213
|
return [workspaceId, sessionId, lease.leaseEpoch, lease.instanceId ?? ""].join("\u0000");
|
|
121
214
|
}
|
|
122
215
|
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
216
|
+
export function isChannelAHandleCacheEntryFresh(
|
|
217
|
+
lastUsedAtMonotonicMs: number,
|
|
218
|
+
nowMonotonicMs: number,
|
|
219
|
+
idleTtlMs = CHANNEL_A_READ_HANDLE_CACHE_IDLE_TTL_MS,
|
|
220
|
+
): boolean {
|
|
221
|
+
return nowMonotonicMs - lastUsedAtMonotonicMs < idleTtlMs;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function isChannelAProcessHandleCacheEntryFresh(
|
|
225
|
+
lastUsedAtMonotonicMs: number,
|
|
226
|
+
nowMonotonicMs: number,
|
|
227
|
+
idleTtlMs = CHANNEL_A_PROCESS_HANDLE_CACHE_IDLE_TTL_MS,
|
|
228
|
+
): boolean {
|
|
229
|
+
return nowMonotonicMs - lastUsedAtMonotonicMs < idleTtlMs;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pruneEstablishedReadHandleCache(nowMonotonicMs: number): void {
|
|
233
|
+
for (const [key, entry] of establishedReadHandleCache) {
|
|
234
|
+
if (!isChannelAHandleCacheEntryFresh(entry.lastUsedAtMonotonicMs, nowMonotonicMs)) {
|
|
235
|
+
establishedReadHandleCache.delete(key);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function pruneEstablishedProcessHandleCache(nowMonotonicMs: number): void {
|
|
241
|
+
for (const [key, entry] of establishedProcessHandleCache) {
|
|
242
|
+
if (!isChannelAProcessHandleCacheEntryFresh(entry.lastUsedAtMonotonicMs, nowMonotonicMs)) {
|
|
243
|
+
establishedProcessHandleCache.delete(key);
|
|
127
244
|
}
|
|
128
245
|
}
|
|
129
|
-
|
|
130
|
-
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function enforceEstablishedHandleCacheSize<T>(cache: Map<string, T>): void {
|
|
249
|
+
while (cache.size > CHANNEL_A_HANDLE_CACHE_MAX_ENTRIES) {
|
|
250
|
+
const oldestKey = cache.keys().next().value as string | undefined;
|
|
131
251
|
if (oldestKey === undefined) break;
|
|
132
|
-
|
|
252
|
+
cache.delete(oldestKey);
|
|
133
253
|
}
|
|
134
254
|
}
|
|
135
255
|
|
|
136
|
-
async function
|
|
256
|
+
async function establishCachedReadHandle(
|
|
137
257
|
key: string,
|
|
138
258
|
establish: () => Promise<EstablishedSandboxSession>,
|
|
139
259
|
): Promise<EstablishedSandboxSession> {
|
|
140
|
-
const now =
|
|
141
|
-
|
|
142
|
-
const cached =
|
|
260
|
+
const now = performance.now();
|
|
261
|
+
pruneEstablishedReadHandleCache(now);
|
|
262
|
+
const cached = establishedReadHandleCache.get(key);
|
|
143
263
|
if (cached) {
|
|
144
|
-
cached.
|
|
264
|
+
cached.lastUsedAtMonotonicMs = now;
|
|
145
265
|
// Refresh insertion order so the bounded map evicts the least-recently used
|
|
146
266
|
// exact lease identity first.
|
|
147
|
-
|
|
148
|
-
|
|
267
|
+
establishedReadHandleCache.delete(key);
|
|
268
|
+
establishedReadHandleCache.set(key, cached);
|
|
149
269
|
return await cached.promise;
|
|
150
270
|
}
|
|
151
271
|
|
|
152
272
|
const promise = establish();
|
|
153
|
-
const entry:
|
|
154
|
-
|
|
273
|
+
const entry: CachedReadHandle = { promise, lastUsedAtMonotonicMs: now };
|
|
274
|
+
establishedReadHandleCache.set(key, entry);
|
|
275
|
+
enforceEstablishedHandleCacheSize(establishedReadHandleCache);
|
|
155
276
|
try {
|
|
156
277
|
return await promise;
|
|
157
278
|
} catch (error) {
|
|
158
|
-
if (
|
|
279
|
+
if (establishedReadHandleCache.get(key) === entry) establishedReadHandleCache.delete(key);
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function establishCachedProcessHandle(
|
|
285
|
+
key: string,
|
|
286
|
+
establish: () => Promise<EstablishedSandboxSession>,
|
|
287
|
+
): Promise<EstablishedSandboxSession> {
|
|
288
|
+
const now = performance.now();
|
|
289
|
+
pruneEstablishedProcessHandleCache(now);
|
|
290
|
+
const cached = establishedProcessHandleCache.get(key);
|
|
291
|
+
if (cached) {
|
|
292
|
+
cached.lastUsedAtMonotonicMs = now;
|
|
293
|
+
establishedProcessHandleCache.delete(key);
|
|
294
|
+
establishedProcessHandleCache.set(key, cached);
|
|
295
|
+
return await cached.promise;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const promise = establish();
|
|
299
|
+
const entry: CachedProcessHandle = { promise, lastUsedAtMonotonicMs: now };
|
|
300
|
+
establishedProcessHandleCache.set(key, entry);
|
|
301
|
+
enforceEstablishedHandleCacheSize(establishedProcessHandleCache);
|
|
302
|
+
try {
|
|
303
|
+
return await promise;
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (establishedProcessHandleCache.get(key) === entry) {
|
|
306
|
+
establishedProcessHandleCache.delete(key);
|
|
307
|
+
}
|
|
159
308
|
throw error;
|
|
160
309
|
}
|
|
161
310
|
}
|
|
@@ -169,18 +318,153 @@ export async function establishCachedChannelAHandle(
|
|
|
169
318
|
lease: LeaseSnapshot,
|
|
170
319
|
establish: () => Promise<EstablishedSandboxSession>,
|
|
171
320
|
): Promise<EstablishedSandboxSession> {
|
|
172
|
-
return await
|
|
321
|
+
return await establishCachedReadHandle(
|
|
173
322
|
establishedHandleCacheKey(workspaceId, sessionId, lease),
|
|
174
323
|
establish,
|
|
175
324
|
);
|
|
176
325
|
}
|
|
177
326
|
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
|
|
327
|
+
async function establishCachedChannelAProcessHandle(
|
|
328
|
+
workspaceId: string,
|
|
329
|
+
sessionId: string,
|
|
330
|
+
lease: LeaseSnapshot,
|
|
331
|
+
establish: () => Promise<EstablishedSandboxSession>,
|
|
332
|
+
): Promise<EstablishedSandboxSession> {
|
|
333
|
+
return await establishCachedProcessHandle(
|
|
334
|
+
establishedHandleCacheKey(workspaceId, sessionId, lease),
|
|
335
|
+
establish,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Run independent, side-effect-free Channel-A reads concurrently without
|
|
341
|
+
* releasing the direct-request holder while sibling provider commands are
|
|
342
|
+
* still settling. A typed temporary-unavailable failure is retried exactly
|
|
343
|
+
* once after every first attempt has settled; validation, conflict, not-found,
|
|
344
|
+
* and unknown failures are never replayed.
|
|
345
|
+
*/
|
|
346
|
+
export async function runConcurrentChannelAReads<T>(
|
|
347
|
+
operations: readonly (() => Promise<T>)[],
|
|
348
|
+
): Promise<T[]> {
|
|
349
|
+
const values = new Array<T>(operations.length);
|
|
350
|
+
const first = await Promise.allSettled(
|
|
351
|
+
operations.map((operation) => Promise.resolve().then(operation)),
|
|
352
|
+
);
|
|
353
|
+
const retryIndexes: number[] = [];
|
|
354
|
+
|
|
355
|
+
for (const [index, result] of first.entries()) {
|
|
356
|
+
if (result.status === "fulfilled") {
|
|
357
|
+
values[index] = result.value;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (!(result.reason instanceof ChannelAUnavailableError)) {
|
|
361
|
+
throw result.reason;
|
|
362
|
+
}
|
|
363
|
+
retryIndexes.push(index);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (retryIndexes.length === 0) return values;
|
|
367
|
+
|
|
368
|
+
const retried = await Promise.allSettled(
|
|
369
|
+
retryIndexes.map((index) => Promise.resolve().then(operations[index]!)),
|
|
370
|
+
);
|
|
371
|
+
for (const [retryIndex, result] of retried.entries()) {
|
|
372
|
+
if (result.status === "rejected") throw result.reason;
|
|
373
|
+
values[retryIndexes[retryIndex]!] = result.value;
|
|
374
|
+
}
|
|
375
|
+
return values;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
type ChannelAReadRecoveryOptions = {
|
|
379
|
+
/** Modal may expose one more stale command-router route after the first
|
|
380
|
+
* successful handle rebuild. Keep this closed and statically bounded. */
|
|
381
|
+
maxFreshHandleRetries?: 1 | 2;
|
|
382
|
+
/** Never start another provider attempt after the originating request ends. */
|
|
383
|
+
waitSignal?: AbortSignal | undefined;
|
|
384
|
+
/** Additional callback-specific failure that is safe to replay. */
|
|
385
|
+
retryableError?: ((error: unknown) => boolean) | undefined;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** Retry a side-effect-free Channel-A read only after the caller has discarded
|
|
389
|
+
* and freshly re-established its provider handle. The ordinary provider-neutral
|
|
390
|
+
* contract allows one retry; Modal opts into one additional rebuild because a
|
|
391
|
+
* command-router rollover can outlive the first replacement handle. Provider
|
|
392
|
+
* commands are never replayed for validation/conflict/unknown errors, mutation
|
|
393
|
+
* routes never call this helper, and request cancellation stops recovery before
|
|
394
|
+
* another provider command begins. */
|
|
395
|
+
export async function runChannelAReadWithFreshHandleRetry<T>(
|
|
396
|
+
run: () => Promise<T>,
|
|
397
|
+
refreshHandle: (attempt: 1 | 2) => Promise<void>,
|
|
398
|
+
options: ChannelAReadRecoveryOptions = {},
|
|
399
|
+
): Promise<T> {
|
|
400
|
+
const maxFreshHandleRetries = options.maxFreshHandleRetries ?? 1;
|
|
401
|
+
for (let retries = 0; ; retries += 1) {
|
|
402
|
+
options.waitSignal?.throwIfAborted();
|
|
403
|
+
try {
|
|
404
|
+
return await run();
|
|
405
|
+
} catch (error) {
|
|
406
|
+
const retryable =
|
|
407
|
+
error instanceof ChannelAUnavailableError || options.retryableError?.(error) === true;
|
|
408
|
+
if (!retryable || retries >= maxFreshHandleRetries) {
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
options.waitSignal?.throwIfAborted();
|
|
412
|
+
const attempt = retries === 0 ? 1 : 2;
|
|
413
|
+
await refreshHandle(attempt);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function shouldEvictChannelAHandleAfterError(
|
|
419
|
+
error: unknown,
|
|
420
|
+
cacheKind: EstablishedHandleCacheKind,
|
|
421
|
+
): boolean {
|
|
422
|
+
return (
|
|
423
|
+
cacheKind === "read" &&
|
|
424
|
+
(error instanceof ChannelAUnavailableError || isRetryableControllerTransport(error))
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function isRetryableControllerTransport(error: unknown): boolean {
|
|
429
|
+
return (
|
|
430
|
+
error instanceof BrowserControlTransportError ||
|
|
431
|
+
(error instanceof BrowserControlRequestError && error.retryable)
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function evictEstablishedHandle(key: string, cacheKind: EstablishedHandleCacheKind): void {
|
|
436
|
+
if (cacheKind === "read") establishedReadHandleCache.delete(key);
|
|
437
|
+
if (cacheKind === "process") establishedProcessHandleCache.delete(key);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function evictAllEstablishedHandles(key: string): void {
|
|
441
|
+
establishedReadHandleCache.delete(key);
|
|
442
|
+
establishedProcessHandleCache.delete(key);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function rememberEstablishedHandle(
|
|
446
|
+
key: string,
|
|
447
|
+
established: EstablishedSandboxSession,
|
|
448
|
+
cacheKind: Exclude<EstablishedHandleCacheKind, "none">,
|
|
449
|
+
): void {
|
|
450
|
+
const now = performance.now();
|
|
451
|
+
if (cacheKind === "read") {
|
|
452
|
+
pruneEstablishedReadHandleCache(now);
|
|
453
|
+
establishedReadHandleCache.delete(key);
|
|
454
|
+
establishedReadHandleCache.set(key, {
|
|
455
|
+
promise: Promise.resolve(established),
|
|
456
|
+
lastUsedAtMonotonicMs: now,
|
|
457
|
+
});
|
|
458
|
+
enforceEstablishedHandleCacheSize(establishedReadHandleCache);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
pruneEstablishedProcessHandleCache(now);
|
|
462
|
+
establishedProcessHandleCache.delete(key);
|
|
463
|
+
establishedProcessHandleCache.set(key, {
|
|
181
464
|
promise: Promise.resolve(established),
|
|
182
|
-
|
|
465
|
+
lastUsedAtMonotonicMs: now,
|
|
183
466
|
});
|
|
467
|
+
enforceEstablishedHandleCacheSize(establishedProcessHandleCache);
|
|
184
468
|
}
|
|
185
469
|
|
|
186
470
|
/**
|
|
@@ -196,6 +480,28 @@ export async function withChannelA<T>(
|
|
|
196
480
|
services: ChannelAServices,
|
|
197
481
|
ctx: ChannelAContext,
|
|
198
482
|
fn: (handle: ChannelAHandle) => Promise<T>,
|
|
483
|
+
): Promise<T> {
|
|
484
|
+
return await withChannelAOperation(services, ctx, false, fn);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Read-only API-direct seam. Separate requests for the same exact live Modal
|
|
488
|
+
* instance are serialized across API replicas; each request's batched reads
|
|
489
|
+
* remain concurrent behind that one distributed boundary. A typed temporary
|
|
490
|
+
* provider-channel failure gets one retry only after rebuilding the exact
|
|
491
|
+
* lease-fenced handle. */
|
|
492
|
+
export async function withChannelARead<T>(
|
|
493
|
+
services: ChannelAServices,
|
|
494
|
+
ctx: ChannelAContext,
|
|
495
|
+
fn: (handle: ChannelAHandle) => Promise<T>,
|
|
496
|
+
): Promise<T> {
|
|
497
|
+
return await withChannelAOperation(services, ctx, true, fn);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function withChannelAOperation<T>(
|
|
501
|
+
services: ChannelAServices,
|
|
502
|
+
ctx: ChannelAContext,
|
|
503
|
+
readOnly: boolean,
|
|
504
|
+
fn: (handle: ChannelAHandle) => Promise<T>,
|
|
199
505
|
): Promise<T> {
|
|
200
506
|
const { db, settings, bus } = services;
|
|
201
507
|
const onSandboxOperation = services.observability
|
|
@@ -210,10 +516,12 @@ export async function withChannelA<T>(
|
|
|
210
516
|
const sandboxGroupId = session.sandboxGroupId;
|
|
211
517
|
const requestId = crypto.randomUUID();
|
|
212
518
|
const holderId = `direct:${requestId}`;
|
|
519
|
+
const operationStartedAt = performance.now();
|
|
520
|
+
const operation = ctx.operation ?? (readOnly ? "read" : "mutation");
|
|
213
521
|
const leaseTtlMs = settings.sandboxLeaseTtlMs;
|
|
214
522
|
|
|
215
523
|
// The STABLE run-environment used by both a cloud home and a machine home.
|
|
216
|
-
// It also carries the per-session
|
|
524
|
+
// It also carries the per-session Codemode pointer selected below.
|
|
217
525
|
const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
|
|
218
526
|
db,
|
|
219
527
|
settings,
|
|
@@ -239,6 +547,7 @@ export async function withChannelA<T>(
|
|
|
239
547
|
const runEstablished = async (
|
|
240
548
|
routed: EstablishedSandboxSession,
|
|
241
549
|
lease: LeaseSnapshot | null,
|
|
550
|
+
homeSession: ChannelASession,
|
|
242
551
|
): Promise<T> => {
|
|
243
552
|
const emit = async (events: { type: string; payload: unknown }[]): Promise<void> => {
|
|
244
553
|
await appendAndPublishEvents(
|
|
@@ -251,10 +560,10 @@ export async function withChannelA<T>(
|
|
|
251
560
|
};
|
|
252
561
|
const routingSession = routed.session as RoutingSandboxSession;
|
|
253
562
|
const credentialSession = withRunCredentialsSession(routingSession as object, session.id);
|
|
254
|
-
const scopedSession = environment.
|
|
255
|
-
?
|
|
563
|
+
const scopedSession = environment.OPENGENI_CODEMODE_TOKEN_FILE
|
|
564
|
+
? withCodemodeTokenSession(
|
|
256
565
|
credentialSession,
|
|
257
|
-
|
|
566
|
+
codemodeTokenFileFromEnvironment(environment, session.id),
|
|
258
567
|
)
|
|
259
568
|
: credentialSession;
|
|
260
569
|
const service = new SandboxChannelAService({
|
|
@@ -262,7 +571,7 @@ export async function withChannelA<T>(
|
|
|
262
571
|
leaseEpoch: lease?.leaseEpoch ?? session.activeEpoch,
|
|
263
572
|
emit,
|
|
264
573
|
});
|
|
265
|
-
const result = await fn({ service, lease, routingSession, requestId });
|
|
574
|
+
const result = await fn({ service, lease, homeSession, routingSession, requestId });
|
|
266
575
|
// The direct request has accepted the result in memory. Finalize every
|
|
267
576
|
// Connected Machine backend the routing proxy reached so a mid-request
|
|
268
577
|
// route transition cannot leave completed output retained until TTL.
|
|
@@ -276,6 +585,7 @@ export async function withChannelA<T>(
|
|
|
276
585
|
if (session.sandboxBackend === "selfhosted") {
|
|
277
586
|
let established: EstablishedSandboxSession | undefined;
|
|
278
587
|
try {
|
|
588
|
+
ctx.waitSignal?.throwIfAborted();
|
|
279
589
|
const pointer = await readActiveSandbox(db, workspaceId, session.id);
|
|
280
590
|
if (!pointer?.activeSandboxId) {
|
|
281
591
|
throw new HTTPException(409, {
|
|
@@ -319,7 +629,13 @@ export async function withChannelA<T>(
|
|
|
319
629
|
backendId: "selfhosted",
|
|
320
630
|
};
|
|
321
631
|
const routed = wrapChannelABoxWithRouting(
|
|
322
|
-
{
|
|
632
|
+
{
|
|
633
|
+
db,
|
|
634
|
+
settings,
|
|
635
|
+
bus,
|
|
636
|
+
...(onSandboxOperation ? { onSandboxOperation } : {}),
|
|
637
|
+
...(ctx.waitSignal ? { waitSignal: ctx.waitSignal } : {}),
|
|
638
|
+
},
|
|
323
639
|
{
|
|
324
640
|
accountId,
|
|
325
641
|
workspaceId,
|
|
@@ -332,9 +648,18 @@ export async function withChannelA<T>(
|
|
|
332
648
|
},
|
|
333
649
|
established,
|
|
334
650
|
);
|
|
335
|
-
return await runEstablished(routed, null);
|
|
651
|
+
return await runEstablished(routed, null, established.session as ChannelASession);
|
|
336
652
|
} catch (error) {
|
|
337
|
-
|
|
653
|
+
observeChannelAOperationFailure(services, {
|
|
654
|
+
workspaceId,
|
|
655
|
+
sandboxGroupId,
|
|
656
|
+
backend: session.sandboxBackend,
|
|
657
|
+
operation,
|
|
658
|
+
durationMs: performance.now() - operationStartedAt,
|
|
659
|
+
error,
|
|
660
|
+
waitSignal: ctx.waitSignal,
|
|
661
|
+
});
|
|
662
|
+
throw mapChannelAError(error, ctx.waitSignal);
|
|
338
663
|
} finally {
|
|
339
664
|
await dropEstablishedHandle(established);
|
|
340
665
|
}
|
|
@@ -351,37 +676,116 @@ export async function withChannelA<T>(
|
|
|
351
676
|
});
|
|
352
677
|
};
|
|
353
678
|
|
|
354
|
-
// Acquire exact request authority; the cold->warming CAS spawns the box when
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
679
|
+
// Acquire exact request authority; the cold->warming CAS spawns the box when
|
|
680
|
+
// cold. This wait is request-abort aware and must pass through the same typed
|
|
681
|
+
// cancellation/diagnostic seam as provider execution below.
|
|
682
|
+
let acquired: Awaited<ReturnType<typeof acquireLease>>;
|
|
683
|
+
let acquisitionMayHaveCommitted = false;
|
|
684
|
+
try {
|
|
685
|
+
ctx.waitSignal?.throwIfAborted();
|
|
686
|
+
acquisitionMayHaveCommitted = true;
|
|
687
|
+
acquired = await acquireLease(db, {
|
|
688
|
+
accountId,
|
|
689
|
+
workspaceId,
|
|
690
|
+
sandboxGroupId,
|
|
691
|
+
kind: "direct",
|
|
692
|
+
holderId,
|
|
693
|
+
subjectId: session.id,
|
|
694
|
+
backend: session.sandboxBackend,
|
|
695
|
+
os: session.sandboxOs,
|
|
696
|
+
leaseTtlMs,
|
|
697
|
+
warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
|
|
698
|
+
captureWaitMs: sandboxLifecycleTransitionWaitMs(settings),
|
|
699
|
+
...(ctx.waitSignal ? { waitSignal: ctx.waitSignal } : {}),
|
|
373
700
|
});
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
701
|
+
// Close the commit/abort race before any provider handle is established.
|
|
702
|
+
// The catch below drops an exact holder committed just before disconnect.
|
|
703
|
+
ctx.waitSignal?.throwIfAborted();
|
|
704
|
+
|
|
705
|
+
if (acquired.role === "blocked") {
|
|
706
|
+
throw new HTTPException(409, {
|
|
707
|
+
message: `sandbox recovery ${acquired.lease.recovery.restore.status} at epoch ${acquired.lease.leaseEpoch}`,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
if (acquired.role === "fenced") {
|
|
711
|
+
throw new HTTPException(409, {
|
|
712
|
+
message:
|
|
713
|
+
acquired.reason === "superseded"
|
|
714
|
+
? `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); retry`
|
|
715
|
+
: `sandbox lifecycle transition in progress (${acquired.reason}, epoch ${acquired.lease.leaseEpoch}, backend ${acquired.lease.backend}, instance ${acquired.lease.instanceId ?? "none"}); retry`,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
} catch (error) {
|
|
719
|
+
// Release is idempotent. If acquisition committed before the request was
|
|
720
|
+
// cancelled, this removes that exact direct holder; a transient DB failure
|
|
721
|
+
// must not overwrite the original structural error (holder TTL is the final
|
|
722
|
+
// cleanup fence).
|
|
723
|
+
if (acquisitionMayHaveCommitted) await release().catch(() => undefined);
|
|
724
|
+
observeChannelAOperationFailure(services, {
|
|
725
|
+
workspaceId,
|
|
726
|
+
sandboxGroupId,
|
|
727
|
+
backend: session.sandboxBackend,
|
|
728
|
+
operation,
|
|
729
|
+
durationMs: performance.now() - operationStartedAt,
|
|
730
|
+
error,
|
|
731
|
+
waitSignal: ctx.waitSignal,
|
|
379
732
|
});
|
|
733
|
+
throw mapChannelAError(error, ctx.waitSignal);
|
|
380
734
|
}
|
|
381
735
|
|
|
382
736
|
let established: EstablishedSandboxSession | undefined;
|
|
383
737
|
let leaseSnapshot: LeaseSnapshot = acquired.lease;
|
|
384
738
|
let establishedCacheKey: string | null = null;
|
|
739
|
+
let establishedCacheKind: EstablishedHandleCacheKind = "none";
|
|
740
|
+
const requestedCacheKind: Exclude<EstablishedHandleCacheKind, "none"> = readOnly
|
|
741
|
+
? "read"
|
|
742
|
+
: "process";
|
|
743
|
+
|
|
744
|
+
const establishAttachedLiveHandle = async (
|
|
745
|
+
live: LeaseSnapshot,
|
|
746
|
+
cacheKind: EstablishedHandleCacheKind,
|
|
747
|
+
): Promise<{ established: EstablishedSandboxSession; cacheKey: string }> => {
|
|
748
|
+
const cacheKey = establishedHandleCacheKey(workspaceId, session.id, live);
|
|
749
|
+
const establish = () =>
|
|
750
|
+
establishSandboxSessionFromEnvelope(settings, live.resumeState, {
|
|
751
|
+
sessionId: session.id,
|
|
752
|
+
recovery: "resume-only",
|
|
753
|
+
backendOverride: session.sandboxBackend,
|
|
754
|
+
environment,
|
|
755
|
+
});
|
|
756
|
+
try {
|
|
757
|
+
const attached =
|
|
758
|
+
cacheKind === "read"
|
|
759
|
+
? await establishCachedChannelAHandle(workspaceId, session.id, live, establish)
|
|
760
|
+
: cacheKind === "process"
|
|
761
|
+
? await establishCachedChannelAProcessHandle(workspaceId, session.id, live, establish)
|
|
762
|
+
: await establish();
|
|
763
|
+
return { established: attached, cacheKey };
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (!isProviderSandboxNotFoundError(session.sandboxBackend, error)) throw error;
|
|
766
|
+
// The exact provider instance is definitively gone, so neither a read nor
|
|
767
|
+
// a process wrapper for that lease identity may survive locally.
|
|
768
|
+
evictAllEstablishedHandles(cacheKey);
|
|
769
|
+
const marked = await markWarmLeaseInstanceLost(db, {
|
|
770
|
+
accountId,
|
|
771
|
+
workspaceId,
|
|
772
|
+
sandboxGroupId,
|
|
773
|
+
expectedEpoch: live.leaseEpoch,
|
|
774
|
+
expectedInstanceId: live.instanceId!,
|
|
775
|
+
});
|
|
776
|
+
if (marked.status === "marked") {
|
|
777
|
+
await appendAndPublishEvents(db, bus, workspaceId, session.id, [
|
|
778
|
+
{
|
|
779
|
+
type: "sandbox.box.lost",
|
|
780
|
+
payload: { sandboxId: live.instanceId },
|
|
781
|
+
},
|
|
782
|
+
]);
|
|
783
|
+
}
|
|
784
|
+
throw new HTTPException(409, {
|
|
785
|
+
message: `sandbox instance was lost; retry to restore it`,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
};
|
|
385
789
|
|
|
386
790
|
try {
|
|
387
791
|
const envelope = await getSandboxSessionEnvelope(db, workspaceId, session.id);
|
|
@@ -415,7 +819,8 @@ export async function withChannelA<T>(
|
|
|
415
819
|
established = result.established;
|
|
416
820
|
leaseSnapshot = result.lease;
|
|
417
821
|
establishedCacheKey = establishedHandleCacheKey(workspaceId, session.id, leaseSnapshot);
|
|
418
|
-
|
|
822
|
+
establishedCacheKind = requestedCacheKind;
|
|
823
|
+
rememberEstablishedHandle(establishedCacheKey, established, establishedCacheKind);
|
|
419
824
|
} catch (error) {
|
|
420
825
|
throw new HTTPException(409, {
|
|
421
826
|
message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})`,
|
|
@@ -436,64 +841,154 @@ export async function withChannelA<T>(
|
|
|
436
841
|
});
|
|
437
842
|
}
|
|
438
843
|
leaseSnapshot = live;
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
844
|
+
const attached = await establishAttachedLiveHandle(live, requestedCacheKind);
|
|
845
|
+
established = attached.established;
|
|
846
|
+
establishedCacheKey = attached.cacheKey;
|
|
847
|
+
establishedCacheKind = requestedCacheKind;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const runProviderOperation = async (): Promise<T> => {
|
|
851
|
+
// Route every call through the same proxy, even when hot-swap is disabled:
|
|
852
|
+
// routing may be dormant, but its direct mutation admission is mandatory for
|
|
853
|
+
// every persistable provider write.
|
|
854
|
+
const routed = wrapChannelABoxWithRouting(
|
|
855
|
+
{
|
|
856
|
+
db,
|
|
857
|
+
settings,
|
|
858
|
+
bus,
|
|
859
|
+
...(onSandboxOperation ? { onSandboxOperation } : {}),
|
|
860
|
+
...(ctx.waitSignal ? { waitSignal: ctx.waitSignal } : {}),
|
|
861
|
+
},
|
|
862
|
+
{
|
|
455
863
|
accountId,
|
|
456
864
|
workspaceId,
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
865
|
+
sessionId: session.id,
|
|
866
|
+
homeLease: {
|
|
867
|
+
sandboxGroupId,
|
|
868
|
+
leaseEpoch: leaseSnapshot.leaseEpoch,
|
|
869
|
+
instanceId: leaseSnapshot.instanceId!,
|
|
870
|
+
backend: session.sandboxBackend,
|
|
871
|
+
},
|
|
872
|
+
directRequest: { requestId, holderId },
|
|
873
|
+
},
|
|
874
|
+
established!,
|
|
875
|
+
);
|
|
876
|
+
const run = async () =>
|
|
877
|
+
await runEstablished(routed, leaseSnapshot, established!.session as ChannelASession);
|
|
878
|
+
return readOnly && session.sandboxBackend === "modal"
|
|
879
|
+
? await withSandboxProviderReadLock(
|
|
880
|
+
db,
|
|
463
881
|
{
|
|
464
|
-
|
|
465
|
-
|
|
882
|
+
workspaceId,
|
|
883
|
+
sandboxGroupId,
|
|
884
|
+
leaseEpoch: leaseSnapshot.leaseEpoch,
|
|
885
|
+
instanceId: leaseSnapshot.instanceId!,
|
|
466
886
|
},
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
}
|
|
887
|
+
ctx.waitSignal,
|
|
888
|
+
run,
|
|
889
|
+
)
|
|
890
|
+
: await run();
|
|
891
|
+
};
|
|
474
892
|
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
893
|
+
// A failed attempt leaves the advisory-lock transaction before refresh;
|
|
894
|
+
// the retry acquires a new transaction/lock against the same fenced lease.
|
|
895
|
+
return readOnly
|
|
896
|
+
? await runChannelAReadWithFreshHandleRetry(
|
|
897
|
+
runProviderOperation,
|
|
898
|
+
async (attempt) => {
|
|
899
|
+
const refreshStartedAt = performance.now();
|
|
900
|
+
const observeRefresh = (outcome: "ok" | "failed"): void => {
|
|
901
|
+
if (!services.observability) return;
|
|
902
|
+
const attributes = {
|
|
903
|
+
sandboxLeaseKey: sandboxLeaseTelemetryKey(workspaceId, sandboxGroupId),
|
|
904
|
+
backend: session.sandboxBackend,
|
|
905
|
+
reason: "provider_handle_unavailable",
|
|
906
|
+
outcome,
|
|
907
|
+
attempt,
|
|
908
|
+
durationMs: Math.max(0, Math.round(performance.now() - refreshStartedAt)),
|
|
909
|
+
};
|
|
910
|
+
try {
|
|
911
|
+
services.observability.incrementCounter({
|
|
912
|
+
name: "opengeni_channel_a_handle_refresh_total",
|
|
913
|
+
help: "Channel-A provider handles rebuilt after a typed temporary-unavailable read.",
|
|
914
|
+
labels: { backend: session.sandboxBackend, outcome },
|
|
915
|
+
});
|
|
916
|
+
} catch {
|
|
917
|
+
// Metrics can never alter lease or provider authority.
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
if (outcome === "ok") {
|
|
921
|
+
services.observability.info(
|
|
922
|
+
"Channel-A provider handle refresh completed",
|
|
923
|
+
attributes,
|
|
924
|
+
);
|
|
925
|
+
} else {
|
|
926
|
+
services.observability.warn(
|
|
927
|
+
"Channel-A provider handle refresh failed",
|
|
928
|
+
attributes,
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
} catch {
|
|
932
|
+
// Logs can never alter lease or provider authority.
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
try {
|
|
936
|
+
if (establishedCacheKey) {
|
|
937
|
+
evictEstablishedHandle(establishedCacheKey, establishedCacheKind);
|
|
938
|
+
}
|
|
939
|
+
await dropEstablishedHandle(established);
|
|
940
|
+
// This request still owns its direct holder, so the exact live identity
|
|
941
|
+
// should be stable. Revalidate it before rebuilding the provider handle.
|
|
942
|
+
const live = await readLease(db, workspaceId, sandboxGroupId);
|
|
943
|
+
if (
|
|
944
|
+
!live ||
|
|
945
|
+
live.liveness !== "warm" ||
|
|
946
|
+
live.leaseEpoch !== leaseSnapshot.leaseEpoch ||
|
|
947
|
+
live.instanceId !== leaseSnapshot.instanceId
|
|
948
|
+
) {
|
|
949
|
+
throw new HTTPException(409, {
|
|
950
|
+
message: `sandbox lease changed while refreshing its provider handle; retry`,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
const refreshed = await establishAttachedLiveHandle(live, "none");
|
|
954
|
+
established = refreshed.established;
|
|
955
|
+
establishedCacheKey = refreshed.cacheKey;
|
|
956
|
+
establishedCacheKind = "read";
|
|
957
|
+
leaseSnapshot = live;
|
|
958
|
+
rememberEstablishedHandle(refreshed.cacheKey, refreshed.established, "read");
|
|
959
|
+
observeRefresh("ok");
|
|
960
|
+
} catch (error) {
|
|
961
|
+
observeRefresh("failed");
|
|
962
|
+
throw error;
|
|
963
|
+
}
|
|
964
|
+
},
|
|
965
|
+
{
|
|
966
|
+
maxFreshHandleRetries: session.sandboxBackend === "modal" ? 2 : 1,
|
|
967
|
+
...(ctx.waitSignal ? { waitSignal: ctx.waitSignal } : {}),
|
|
968
|
+
...(ctx.retryControllerTransport
|
|
969
|
+
? { retryableError: isRetryableControllerTransport }
|
|
970
|
+
: {}),
|
|
971
|
+
},
|
|
972
|
+
)
|
|
973
|
+
: await runProviderOperation();
|
|
495
974
|
} catch (error) {
|
|
496
|
-
|
|
975
|
+
// A read wrapper carries no yielded process state and is safe to discard.
|
|
976
|
+
// A mutation/terminal wrapper may own the SDK's only local process object;
|
|
977
|
+
// retain it after an ambiguous transport failure so a later control call
|
|
978
|
+
// can use the in-place provider transport recovery without losing the PTY.
|
|
979
|
+
if (establishedCacheKey && shouldEvictChannelAHandleAfterError(error, establishedCacheKind)) {
|
|
980
|
+
evictEstablishedHandle(establishedCacheKey, establishedCacheKind);
|
|
981
|
+
}
|
|
982
|
+
observeChannelAOperationFailure(services, {
|
|
983
|
+
workspaceId,
|
|
984
|
+
sandboxGroupId,
|
|
985
|
+
backend: session.sandboxBackend,
|
|
986
|
+
operation,
|
|
987
|
+
durationMs: performance.now() - operationStartedAt,
|
|
988
|
+
error,
|
|
989
|
+
waitSignal: ctx.waitSignal,
|
|
990
|
+
});
|
|
991
|
+
throw mapChannelAError(error, ctx.waitSignal);
|
|
497
992
|
} finally {
|
|
498
993
|
await release();
|
|
499
994
|
await dropEstablishedHandle(established);
|
|
@@ -502,8 +997,20 @@ export async function withChannelA<T>(
|
|
|
502
997
|
|
|
503
998
|
/** Map the service's typed errors to HTTP status (the §5.3 matrix). Re-throws an
|
|
504
999
|
* already-HTTPException unchanged. */
|
|
505
|
-
export function mapChannelAError(error: unknown): unknown {
|
|
1000
|
+
export function mapChannelAError(error: unknown, waitSignal?: AbortSignal): unknown {
|
|
506
1001
|
if (error instanceof HTTPException) return error;
|
|
1002
|
+
if (isChannelARequestCancellation(error, waitSignal))
|
|
1003
|
+
return new HTTPException(499 as never, {
|
|
1004
|
+
message: "request cancelled",
|
|
1005
|
+
cause: error,
|
|
1006
|
+
});
|
|
1007
|
+
if (
|
|
1008
|
+
error instanceof SandboxResumeIdentityMismatchError ||
|
|
1009
|
+
error instanceof SandboxResumeIdentityUnavailableError
|
|
1010
|
+
)
|
|
1011
|
+
return new HTTPException(409, { message: error.message });
|
|
1012
|
+
if (error instanceof SandboxProviderReadLockUnavailableError)
|
|
1013
|
+
return new HTTPException(503, { message: error.message });
|
|
507
1014
|
if (error instanceof ChannelAUnavailableError)
|
|
508
1015
|
return new HTTPException(503, { message: error.message });
|
|
509
1016
|
if (error instanceof ChannelAValidationError)
|
|
@@ -517,6 +1024,133 @@ export function mapChannelAError(error: unknown): unknown {
|
|
|
517
1024
|
return error;
|
|
518
1025
|
}
|
|
519
1026
|
|
|
1027
|
+
export function isChannelARequestCancellation(error: unknown, waitSignal?: AbortSignal): boolean {
|
|
1028
|
+
if (waitSignal?.aborted !== true) return false;
|
|
1029
|
+
const isSignalReason = waitSignal.reason !== undefined && error === waitSignal.reason;
|
|
1030
|
+
const isAbortError =
|
|
1031
|
+
(error instanceof DOMException && error.name === "AbortError") ||
|
|
1032
|
+
(error instanceof Error && error.name === "AbortError");
|
|
1033
|
+
return isSignalReason || isAbortError;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Structural classification only: exact provider exception text, codes, URLs,
|
|
1037
|
+
* and identifiers never cross the telemetry boundary. */
|
|
1038
|
+
export function channelAOperationFailureDiagnostic(
|
|
1039
|
+
error: unknown,
|
|
1040
|
+
waitSignal?: AbortSignal,
|
|
1041
|
+
): ChannelAOperationFailureDiagnostic {
|
|
1042
|
+
if (isChannelARequestCancellation(error, waitSignal)) {
|
|
1043
|
+
return {
|
|
1044
|
+
reason: "request_cancelled",
|
|
1045
|
+
status: 499,
|
|
1046
|
+
errorCode: "sandbox_channel_a_cancelled",
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
if (error instanceof SandboxProviderReadLockUnavailableError) {
|
|
1050
|
+
return {
|
|
1051
|
+
reason: "provider_read_busy",
|
|
1052
|
+
status: 503,
|
|
1053
|
+
errorCode: "sandbox_channel_a_provider_busy",
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
if (error instanceof ChannelAUnavailableError) {
|
|
1057
|
+
return {
|
|
1058
|
+
reason: "provider_unavailable",
|
|
1059
|
+
status: 503,
|
|
1060
|
+
errorCode: "sandbox_channel_a_provider_unavailable",
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
if (
|
|
1064
|
+
error instanceof SandboxResumeIdentityMismatchError ||
|
|
1065
|
+
error instanceof SandboxResumeIdentityUnavailableError ||
|
|
1066
|
+
(error instanceof HTTPException && error.status === 409)
|
|
1067
|
+
) {
|
|
1068
|
+
return {
|
|
1069
|
+
reason: "lifecycle_conflict",
|
|
1070
|
+
status: 409,
|
|
1071
|
+
errorCode: "sandbox_channel_a_lifecycle_conflict",
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
if (
|
|
1075
|
+
error instanceof ChannelAValidationError ||
|
|
1076
|
+
error instanceof ChannelANotFoundError ||
|
|
1077
|
+
error instanceof ChannelAConflictError ||
|
|
1078
|
+
error instanceof ChannelAUnsupportedError
|
|
1079
|
+
) {
|
|
1080
|
+
const mapped = mapChannelAError(error, waitSignal);
|
|
1081
|
+
return {
|
|
1082
|
+
reason: "request_rejected",
|
|
1083
|
+
status: mapped instanceof HTTPException ? mapped.status : 500,
|
|
1084
|
+
errorCode: "sandbox_channel_a_operation_failed",
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
if (error instanceof HTTPException) {
|
|
1088
|
+
return {
|
|
1089
|
+
reason: error.status >= 500 ? "unexpected" : "request_rejected",
|
|
1090
|
+
status: error.status,
|
|
1091
|
+
errorCode: "sandbox_channel_a_operation_failed",
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
return {
|
|
1095
|
+
reason: "unexpected",
|
|
1096
|
+
status: 500,
|
|
1097
|
+
errorCode: "sandbox_channel_a_operation_failed",
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function observeChannelAOperationFailure(
|
|
1102
|
+
services: ChannelAServices,
|
|
1103
|
+
input: {
|
|
1104
|
+
workspaceId: string;
|
|
1105
|
+
sandboxGroupId: string;
|
|
1106
|
+
backend: string;
|
|
1107
|
+
operation: string;
|
|
1108
|
+
durationMs: number;
|
|
1109
|
+
error: unknown;
|
|
1110
|
+
waitSignal?: AbortSignal | undefined;
|
|
1111
|
+
},
|
|
1112
|
+
): void {
|
|
1113
|
+
if (!services.observability) return;
|
|
1114
|
+
const diagnostic = channelAOperationFailureDiagnostic(input.error, input.waitSignal);
|
|
1115
|
+
const attributes = {
|
|
1116
|
+
sandboxLeaseKey: sandboxLeaseTelemetryKey(input.workspaceId, input.sandboxGroupId),
|
|
1117
|
+
backend: input.backend,
|
|
1118
|
+
op: input.operation,
|
|
1119
|
+
outcome: "failed",
|
|
1120
|
+
reason: diagnostic.reason,
|
|
1121
|
+
status: diagnostic.status,
|
|
1122
|
+
durationMs: Math.max(0, Math.round(input.durationMs)),
|
|
1123
|
+
errorClass: "SandboxChannelAOperationError",
|
|
1124
|
+
errorCode: diagnostic.errorCode,
|
|
1125
|
+
origin: "api",
|
|
1126
|
+
} as const;
|
|
1127
|
+
try {
|
|
1128
|
+
services.observability.incrementCounter({
|
|
1129
|
+
name: "opengeni_channel_a_operation_failures_total",
|
|
1130
|
+
help: "Channel-A failures by bounded operation and structural reason.",
|
|
1131
|
+
labels: {
|
|
1132
|
+
backend: input.backend,
|
|
1133
|
+
op: input.operation,
|
|
1134
|
+
reason: diagnostic.reason,
|
|
1135
|
+
status: String(diagnostic.status),
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
} catch {
|
|
1139
|
+
// Metrics can never alter request or lease settlement.
|
|
1140
|
+
}
|
|
1141
|
+
try {
|
|
1142
|
+
if (diagnostic.reason === "request_cancelled") {
|
|
1143
|
+
services.observability.info("Channel-A request cancelled", attributes);
|
|
1144
|
+
} else if (diagnostic.reason === "request_rejected") {
|
|
1145
|
+
services.observability.debug("Channel-A request rejected", attributes);
|
|
1146
|
+
} else {
|
|
1147
|
+
services.observability.warn("Channel-A operation failed", attributes);
|
|
1148
|
+
}
|
|
1149
|
+
} catch {
|
|
1150
|
+
// Logs can never alter request or lease settlement.
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
520
1154
|
// Drop a transiently-established, NON-OWNED handle WITHOUT terminating the box.
|
|
521
1155
|
// The box is owned by the LEASE (resumed by id); this handle is incidental.
|
|
522
1156
|
//
|