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