@opengeni/api-router 0.22.2 → 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/auth/managed-auth.d.ts +0 -30
- package/dist/browser-controller-authority.d.ts +43 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-HWXJW5C7.js → chunk-JIKNR5YL.js} +27993 -14546
- 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/http/sse.d.ts +2 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1824 -30
- 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 +30 -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 +14 -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/receipts.d.ts +28 -0
- package/dist/mcp/scheduled-task-view.d.ts +518 -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/auth-callout.d.ts +2 -0
- package/dist/sandbox/channel-a.d.ts +59 -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 +277 -41
- package/src/auth/managed-auth.ts +0 -16
- 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/http/sse.ts +101 -6
- package/src/index.ts +147 -23
- 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 +159 -89
- package/src/integrations/provider-oauth.ts +777 -0
- package/src/integrations/slack-bot.ts +31 -2
- package/src/integrations/slack-interactions.ts +610 -42
- package/src/integrations/social-api.ts +11 -0
- package/src/mcp/documents.ts +74 -26
- package/src/mcp/editable-artifact-query-schema.ts +236 -0
- package/src/mcp/editable-artifacts.ts +448 -0
- package/src/mcp/receipts.ts +95 -0
- package/src/mcp/scheduled-task-view.ts +642 -0
- package/src/mcp/server.ts +1718 -310
- package/src/memory-slack-delivery.ts +209 -0
- package/src/observability.ts +3 -3
- package/src/routes/api-integrations.ts +407 -0
- package/src/routes/api-keys.ts +7 -1
- package/src/routes/browser-identities.ts +136 -0
- package/src/routes/browser-sessions.ts +2543 -0
- package/src/routes/codex.ts +7 -4
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1247 -0
- package/src/routes/connections.ts +358 -102
- package/src/routes/documents.ts +22 -3
- package/src/routes/editable-artifacts.ts +1159 -0
- package/src/routes/enrollments.ts +54 -12
- package/src/routes/environments.ts +60 -11
- package/src/routes/files.ts +277 -65
- package/src/routes/github.ts +18 -2
- package/src/routes/install.ts +38 -2
- package/src/routes/integration-features.ts +258 -0
- package/src/routes/machines.ts +1 -1
- 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 +94 -42
- package/src/routes/sessions.ts +475 -234
- package/src/routes/skills.ts +174 -0
- package/src/routes/transcription-recordings.ts +65 -33
- package/src/routes/video-generation.ts +132 -0
- package/src/routes/workspaces.ts +46 -24
- package/src/sandbox/auth-callout.ts +16 -4
- package/src/sandbox/channel-a.ts +809 -85
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +58 -29
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/dist/chunk-HWXJW5C7.js.map +0 -1
- package/dist/mcp/toolspace.d.ts +0 -62
- package/src/mcp/toolspace.ts +0 -1186
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";
|
|
@@ -30,17 +30,24 @@ import type { Session } from "@opengeni/contracts";
|
|
|
30
30
|
import {
|
|
31
31
|
acquireLease,
|
|
32
32
|
getSandboxSessionEnvelope,
|
|
33
|
+
getEnrollment,
|
|
33
34
|
getSandbox,
|
|
34
35
|
loadWorkspaceEnvironmentForRun,
|
|
35
36
|
markWarmLeaseInstanceLost,
|
|
36
37
|
readActiveSandbox,
|
|
37
38
|
readLease,
|
|
38
39
|
releaseLeaseHolder,
|
|
40
|
+
SandboxProviderReadLockUnavailableError,
|
|
41
|
+
withSandboxProviderReadLock,
|
|
39
42
|
type Database,
|
|
40
43
|
type LeaseSnapshot,
|
|
41
44
|
} from "@opengeni/db";
|
|
42
45
|
import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
|
|
43
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
sandboxLeaseTelemetryKey,
|
|
48
|
+
sandboxOperationMetricObserver,
|
|
49
|
+
type Observability,
|
|
50
|
+
} from "@opengeni/observability";
|
|
44
51
|
import { HTTPException } from "hono/http-exception";
|
|
45
52
|
|
|
46
53
|
import {
|
|
@@ -49,13 +56,16 @@ import {
|
|
|
49
56
|
isProviderSandboxNotFoundError,
|
|
50
57
|
SandboxChannelAService,
|
|
51
58
|
NatsControlRpc,
|
|
59
|
+
NatsOpStreamTransport,
|
|
60
|
+
SandboxResumeIdentityMismatchError,
|
|
61
|
+
SandboxResumeIdentityUnavailableError,
|
|
52
62
|
ChannelAConflictError,
|
|
53
63
|
ChannelANotFoundError,
|
|
54
64
|
ChannelAUnsupportedError,
|
|
55
65
|
ChannelAUnavailableError,
|
|
56
66
|
ChannelAValidationError,
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
codemodeTokenFileFromEnvironment,
|
|
68
|
+
withCodemodeTokenSession,
|
|
59
69
|
withRunCredentialsSession,
|
|
60
70
|
type ChannelASession,
|
|
61
71
|
type EstablishedSandboxSession,
|
|
@@ -71,12 +81,66 @@ export type ChannelAServices = {
|
|
|
71
81
|
observability?: Observability | undefined;
|
|
72
82
|
};
|
|
73
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
|
+
|
|
74
115
|
export type ChannelAContext = {
|
|
75
116
|
accountId: string;
|
|
76
117
|
workspaceId: string;
|
|
77
118
|
session: Session;
|
|
78
119
|
// The principal that drives the op (for emit attribution + pty opened_by).
|
|
79
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";
|
|
80
144
|
};
|
|
81
145
|
|
|
82
146
|
// The live op surface handed to a route's callback: the service + the live lease
|
|
@@ -86,10 +150,299 @@ export type ChannelAHandle = {
|
|
|
86
150
|
/** Connected Machine homes deliberately have no cloud lease. Durable PTYs
|
|
87
151
|
* require a real home-provider lease and reject this null case. */
|
|
88
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;
|
|
89
157
|
routingSession: RoutingSandboxSession;
|
|
90
158
|
requestId: string;
|
|
91
159
|
};
|
|
92
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Provider handles are lightweight references to a lease-owned sandbox, but
|
|
163
|
+
* reconstructing one is not free: Modal resume-by-id plus its first command can
|
|
164
|
+
* dominate a small Git/files read. Workspace panels issue several independent
|
|
165
|
+
* Channel-A requests together, so reuse the exact fenced handle briefly instead
|
|
166
|
+
* of making every request reattach to the same warm instance.
|
|
167
|
+
*
|
|
168
|
+
* The key includes the session, lease epoch, and immutable provider instance id.
|
|
169
|
+
* A rotation can therefore never inherit an old handle. Entries are bounded and
|
|
170
|
+
* expire opportunistically; eviction only drops local references and never
|
|
171
|
+
* terminates the lease-owned sandbox.
|
|
172
|
+
*/
|
|
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;
|
|
185
|
+
const CHANNEL_A_HANDLE_CACHE_MAX_ENTRIES = 64;
|
|
186
|
+
type CachedReadHandle = {
|
|
187
|
+
promise: Promise<EstablishedSandboxSession>;
|
|
188
|
+
lastUsedAtMonotonicMs: number;
|
|
189
|
+
};
|
|
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>();
|
|
197
|
+
|
|
198
|
+
function establishedHandleCacheKey(
|
|
199
|
+
workspaceId: string,
|
|
200
|
+
sessionId: string,
|
|
201
|
+
lease: LeaseSnapshot,
|
|
202
|
+
): string {
|
|
203
|
+
return [workspaceId, sessionId, lease.leaseEpoch, lease.instanceId ?? ""].join("\u0000");
|
|
204
|
+
}
|
|
205
|
+
|
|
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);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
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;
|
|
241
|
+
if (oldestKey === undefined) break;
|
|
242
|
+
cache.delete(oldestKey);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function establishCachedReadHandle(
|
|
247
|
+
key: string,
|
|
248
|
+
establish: () => Promise<EstablishedSandboxSession>,
|
|
249
|
+
): Promise<EstablishedSandboxSession> {
|
|
250
|
+
const now = performance.now();
|
|
251
|
+
pruneEstablishedReadHandleCache(now);
|
|
252
|
+
const cached = establishedReadHandleCache.get(key);
|
|
253
|
+
if (cached) {
|
|
254
|
+
cached.lastUsedAtMonotonicMs = now;
|
|
255
|
+
// Refresh insertion order so the bounded map evicts the least-recently used
|
|
256
|
+
// exact lease identity first.
|
|
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);
|
|
285
|
+
return await cached.promise;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const promise = establish();
|
|
289
|
+
const entry: CachedProcessHandle = { promise, lastUsedAtMonotonicMs: now };
|
|
290
|
+
establishedProcessHandleCache.set(key, entry);
|
|
291
|
+
enforceEstablishedHandleCacheSize(establishedProcessHandleCache);
|
|
292
|
+
try {
|
|
293
|
+
return await promise;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (establishedProcessHandleCache.get(key) === entry) {
|
|
296
|
+
establishedProcessHandleCache.delete(key);
|
|
297
|
+
}
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Reuse the exact lease-fenced provider handle across API-direct surfaces.
|
|
303
|
+
* Stream capability negotiation and the first Files/Changes reads commonly run
|
|
304
|
+
* back-to-back; sharing this handle avoids paying the same Modal resume twice. */
|
|
305
|
+
export async function establishCachedChannelAHandle(
|
|
306
|
+
workspaceId: string,
|
|
307
|
+
sessionId: string,
|
|
308
|
+
lease: LeaseSnapshot,
|
|
309
|
+
establish: () => Promise<EstablishedSandboxSession>,
|
|
310
|
+
): Promise<EstablishedSandboxSession> {
|
|
311
|
+
return await establishCachedReadHandle(
|
|
312
|
+
establishedHandleCacheKey(workspaceId, sessionId, lease),
|
|
313
|
+
establish,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
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, {
|
|
440
|
+
promise: Promise.resolve(established),
|
|
441
|
+
lastUsedAtMonotonicMs: now,
|
|
442
|
+
});
|
|
443
|
+
enforceEstablishedHandleCacheSize(establishedProcessHandleCache);
|
|
444
|
+
}
|
|
445
|
+
|
|
93
446
|
/**
|
|
94
447
|
* Run a Channel-A op against a live box, API-direct. Acquires an exact direct holder
|
|
95
448
|
* (warming the box when cold), resumes by id, builds the service, runs `fn`, and
|
|
@@ -103,6 +456,28 @@ export async function withChannelA<T>(
|
|
|
103
456
|
services: ChannelAServices,
|
|
104
457
|
ctx: ChannelAContext,
|
|
105
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>,
|
|
106
481
|
): Promise<T> {
|
|
107
482
|
const { db, settings, bus } = services;
|
|
108
483
|
const onSandboxOperation = services.observability
|
|
@@ -117,10 +492,12 @@ export async function withChannelA<T>(
|
|
|
117
492
|
const sandboxGroupId = session.sandboxGroupId;
|
|
118
493
|
const requestId = crypto.randomUUID();
|
|
119
494
|
const holderId = `direct:${requestId}`;
|
|
495
|
+
const operationStartedAt = performance.now();
|
|
496
|
+
const operation = ctx.operation ?? (readOnly ? "read" : "mutation");
|
|
120
497
|
const leaseTtlMs = settings.sandboxLeaseTtlMs;
|
|
121
498
|
|
|
122
499
|
// The STABLE run-environment used by both a cloud home and a machine home.
|
|
123
|
-
// It also carries the per-session
|
|
500
|
+
// It also carries the per-session Codemode pointer selected below.
|
|
124
501
|
const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
|
|
125
502
|
db,
|
|
126
503
|
settings,
|
|
@@ -146,6 +523,7 @@ export async function withChannelA<T>(
|
|
|
146
523
|
const runEstablished = async (
|
|
147
524
|
routed: EstablishedSandboxSession,
|
|
148
525
|
lease: LeaseSnapshot | null,
|
|
526
|
+
homeSession: ChannelASession,
|
|
149
527
|
): Promise<T> => {
|
|
150
528
|
const emit = async (events: { type: string; payload: unknown }[]): Promise<void> => {
|
|
151
529
|
await appendAndPublishEvents(
|
|
@@ -158,10 +536,10 @@ export async function withChannelA<T>(
|
|
|
158
536
|
};
|
|
159
537
|
const routingSession = routed.session as RoutingSandboxSession;
|
|
160
538
|
const credentialSession = withRunCredentialsSession(routingSession as object, session.id);
|
|
161
|
-
const scopedSession = environment.
|
|
162
|
-
?
|
|
539
|
+
const scopedSession = environment.OPENGENI_CODEMODE_TOKEN_FILE
|
|
540
|
+
? withCodemodeTokenSession(
|
|
163
541
|
credentialSession,
|
|
164
|
-
|
|
542
|
+
codemodeTokenFileFromEnvironment(environment, session.id),
|
|
165
543
|
)
|
|
166
544
|
: credentialSession;
|
|
167
545
|
const service = new SandboxChannelAService({
|
|
@@ -169,7 +547,12 @@ export async function withChannelA<T>(
|
|
|
169
547
|
leaseEpoch: lease?.leaseEpoch ?? session.activeEpoch,
|
|
170
548
|
emit,
|
|
171
549
|
});
|
|
172
|
-
|
|
550
|
+
const result = await fn({ service, lease, homeSession, routingSession, requestId });
|
|
551
|
+
// The direct request has accepted the result in memory. Finalize every
|
|
552
|
+
// Connected Machine backend the routing proxy reached so a mid-request
|
|
553
|
+
// route transition cannot leave completed output retained until TTL.
|
|
554
|
+
await routingSession.finalizeOpStreamOps().catch(() => undefined);
|
|
555
|
+
return result;
|
|
173
556
|
};
|
|
174
557
|
|
|
175
558
|
// A machine-targeted top-level session has an honest selfhosted HOME label.
|
|
@@ -178,6 +561,7 @@ export async function withChannelA<T>(
|
|
|
178
561
|
if (session.sandboxBackend === "selfhosted") {
|
|
179
562
|
let established: EstablishedSandboxSession | undefined;
|
|
180
563
|
try {
|
|
564
|
+
ctx.waitSignal?.throwIfAborted();
|
|
181
565
|
const pointer = await readActiveSandbox(db, workspaceId, session.id);
|
|
182
566
|
if (!pointer?.activeSandboxId) {
|
|
183
567
|
throw new HTTPException(409, {
|
|
@@ -190,6 +574,7 @@ export async function withChannelA<T>(
|
|
|
190
574
|
message: "machine-home session points to an unavailable Connected Machine",
|
|
191
575
|
});
|
|
192
576
|
}
|
|
577
|
+
const enrollment = await getEnrollment(db, workspaceId, sandbox.enrollmentId);
|
|
193
578
|
const built = await buildSelfhostedBackendSession({
|
|
194
579
|
workspaceId,
|
|
195
580
|
agentId: sandbox.enrollmentId,
|
|
@@ -200,6 +585,17 @@ export async function withChannelA<T>(
|
|
|
200
585
|
workingDir: pointer.workingDir,
|
|
201
586
|
timeoutMs: settings.sandboxSelfhostedControlTimeoutMs,
|
|
202
587
|
execTimeoutMs: settings.sandboxSelfhostedExecTimeoutMs,
|
|
588
|
+
...(settings.agentOpStreamEnabled === true &&
|
|
589
|
+
enrollment?.opStream === true &&
|
|
590
|
+
bus.getOpStreamConnection
|
|
591
|
+
? {
|
|
592
|
+
opStream: {
|
|
593
|
+
transport: new NatsOpStreamTransport(
|
|
594
|
+
async () => bus.getOpStreamConnection?.() ?? null,
|
|
595
|
+
),
|
|
596
|
+
},
|
|
597
|
+
}
|
|
598
|
+
: {}),
|
|
203
599
|
});
|
|
204
600
|
established = {
|
|
205
601
|
client: built.client,
|
|
@@ -209,7 +605,13 @@ export async function withChannelA<T>(
|
|
|
209
605
|
backendId: "selfhosted",
|
|
210
606
|
};
|
|
211
607
|
const routed = wrapChannelABoxWithRouting(
|
|
212
|
-
{
|
|
608
|
+
{
|
|
609
|
+
db,
|
|
610
|
+
settings,
|
|
611
|
+
bus,
|
|
612
|
+
...(onSandboxOperation ? { onSandboxOperation } : {}),
|
|
613
|
+
...(ctx.waitSignal ? { waitSignal: ctx.waitSignal } : {}),
|
|
614
|
+
},
|
|
213
615
|
{
|
|
214
616
|
accountId,
|
|
215
617
|
workspaceId,
|
|
@@ -222,9 +624,18 @@ export async function withChannelA<T>(
|
|
|
222
624
|
},
|
|
223
625
|
established,
|
|
224
626
|
);
|
|
225
|
-
return await runEstablished(routed, null);
|
|
627
|
+
return await runEstablished(routed, null, established.session as ChannelASession);
|
|
226
628
|
} catch (error) {
|
|
227
|
-
|
|
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);
|
|
228
639
|
} finally {
|
|
229
640
|
await dropEstablishedHandle(established);
|
|
230
641
|
}
|
|
@@ -241,36 +652,116 @@ export async function withChannelA<T>(
|
|
|
241
652
|
});
|
|
242
653
|
};
|
|
243
654
|
|
|
244
|
-
// Acquire exact request authority; the cold->warming CAS spawns the box when
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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 } : {}),
|
|
263
676
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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,
|
|
269
708
|
});
|
|
709
|
+
throw mapChannelAError(error, ctx.waitSignal);
|
|
270
710
|
}
|
|
271
711
|
|
|
272
712
|
let established: EstablishedSandboxSession | undefined;
|
|
273
713
|
let leaseSnapshot: LeaseSnapshot = acquired.lease;
|
|
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
|
+
};
|
|
274
765
|
|
|
275
766
|
try {
|
|
276
767
|
const envelope = await getSandboxSessionEnvelope(db, workspaceId, session.id);
|
|
@@ -303,6 +794,9 @@ export async function withChannelA<T>(
|
|
|
303
794
|
});
|
|
304
795
|
established = result.established;
|
|
305
796
|
leaseSnapshot = result.lease;
|
|
797
|
+
establishedCacheKey = establishedHandleCacheKey(workspaceId, session.id, leaseSnapshot);
|
|
798
|
+
establishedCacheKind = requestedCacheKind;
|
|
799
|
+
rememberEstablishedHandle(establishedCacheKey, established, establishedCacheKind);
|
|
306
800
|
} catch (error) {
|
|
307
801
|
throw new HTTPException(409, {
|
|
308
802
|
message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})`,
|
|
@@ -323,60 +817,151 @@ export async function withChannelA<T>(
|
|
|
323
817
|
});
|
|
324
818
|
}
|
|
325
819
|
leaseSnapshot = live;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
{
|
|
338
839
|
accountId,
|
|
339
840
|
workspaceId,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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,
|
|
346
857
|
{
|
|
347
|
-
|
|
348
|
-
|
|
858
|
+
workspaceId,
|
|
859
|
+
sandboxGroupId,
|
|
860
|
+
leaseEpoch: leaseSnapshot.leaseEpoch,
|
|
861
|
+
instanceId: leaseSnapshot.instanceId!,
|
|
349
862
|
},
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
}
|
|
863
|
+
ctx.waitSignal,
|
|
864
|
+
run,
|
|
865
|
+
)
|
|
866
|
+
: await run();
|
|
867
|
+
};
|
|
357
868
|
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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();
|
|
378
947
|
} catch (error) {
|
|
379
|
-
|
|
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);
|
|
380
965
|
} finally {
|
|
381
966
|
await release();
|
|
382
967
|
await dropEstablishedHandle(established);
|
|
@@ -385,8 +970,20 @@ export async function withChannelA<T>(
|
|
|
385
970
|
|
|
386
971
|
/** Map the service's typed errors to HTTP status (the §5.3 matrix). Re-throws an
|
|
387
972
|
* already-HTTPException unchanged. */
|
|
388
|
-
export function mapChannelAError(error: unknown): unknown {
|
|
973
|
+
export function mapChannelAError(error: unknown, waitSignal?: AbortSignal): unknown {
|
|
389
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 });
|
|
390
987
|
if (error instanceof ChannelAUnavailableError)
|
|
391
988
|
return new HTTPException(503, { message: error.message });
|
|
392
989
|
if (error instanceof ChannelAValidationError)
|
|
@@ -400,6 +997,133 @@ export function mapChannelAError(error: unknown): unknown {
|
|
|
400
997
|
return error;
|
|
401
998
|
}
|
|
402
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
|
+
|
|
403
1127
|
// Drop a transiently-established, NON-OWNED handle WITHOUT terminating the box.
|
|
404
1128
|
// The box is owned by the LEASE (resumed by id); this handle is incidental.
|
|
405
1129
|
//
|