@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.
Files changed (107) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/browser-controller-authority.d.ts +43 -0
  4. package/dist/browser-state-authority.d.ts +27 -0
  5. package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
  6. package/dist/chunk-JIKNR5YL.js.map +1 -0
  7. package/dist/codemode.d.ts +23 -0
  8. package/dist/editable-artifact-live-hints.d.ts +11 -0
  9. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  10. package/dist/editable-artifact-office-import.d.ts +22 -0
  11. package/dist/editable-artifact-production.d.ts +29 -0
  12. package/dist/editable-artifact-websocket.d.ts +49 -0
  13. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  14. package/dist/github-browser-flow.d.ts +6 -0
  15. package/dist/http/cors.d.ts +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1796 -22
  18. package/dist/index.js.map +1 -1
  19. package/dist/integrations/api-integrations.d.ts +24 -0
  20. package/dist/integrations/atlassian.d.ts +176 -0
  21. package/dist/integrations/github-skill-source.d.ts +5 -0
  22. package/dist/integrations/google-drive.d.ts +85 -0
  23. package/dist/integrations/oauth-client.d.ts +22 -1
  24. package/dist/integrations/provider-oauth.d.ts +19 -0
  25. package/dist/integrations/slack-bot.d.ts +4 -0
  26. package/dist/integrations/slack-interactions.d.ts +8 -2
  27. package/dist/integrations/social-api.d.ts +2 -1
  28. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  29. package/dist/mcp/editable-artifacts.d.ts +13 -0
  30. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  31. package/dist/mcp/server.d.ts +14 -3
  32. package/dist/memory-slack-delivery.d.ts +9 -0
  33. package/dist/routes/api-integrations.d.ts +8 -0
  34. package/dist/routes/browser-identities.d.ts +5 -0
  35. package/dist/routes/browser-sessions.d.ts +6 -0
  36. package/dist/routes/company-profile.d.ts +3 -0
  37. package/dist/routes/computer-sessions.d.ts +6 -0
  38. package/dist/routes/editable-artifacts.d.ts +44 -0
  39. package/dist/routes/integration-features.d.ts +3 -0
  40. package/dist/routes/memory-slack-publications.d.ts +6 -0
  41. package/dist/routes/plugins.d.ts +8 -0
  42. package/dist/routes/sessions.d.ts +17 -2
  43. package/dist/routes/skills.d.ts +6 -0
  44. package/dist/routes/video-generation.d.ts +3 -0
  45. package/dist/sandbox/channel-a.d.ts +55 -2
  46. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  47. package/dist/sandbox/viewer.d.ts +4 -2
  48. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  49. package/package.json +19 -14
  50. package/src/app.ts +274 -37
  51. package/src/browser-controller-authority.ts +137 -0
  52. package/src/browser-state-authority.ts +236 -0
  53. package/src/codemode.ts +186 -0
  54. package/src/editable-artifact-live-hints.ts +64 -0
  55. package/src/editable-artifact-native-kernel.ts +659 -0
  56. package/src/editable-artifact-office-import.ts +230 -0
  57. package/src/editable-artifact-production.ts +419 -0
  58. package/src/editable-artifact-websocket.ts +311 -0
  59. package/src/editable-artifact-workspace-files.ts +186 -0
  60. package/src/github-browser-flow.ts +35 -6
  61. package/src/http/auth.ts +2 -0
  62. package/src/http/cors.ts +3 -0
  63. package/src/index.ts +119 -20
  64. package/src/integrations/api-integrations.ts +350 -0
  65. package/src/integrations/atlassian.ts +1621 -0
  66. package/src/integrations/github-skill-source.ts +142 -0
  67. package/src/integrations/google-drive.ts +1000 -64
  68. package/src/integrations/oauth-client.ts +123 -33
  69. package/src/integrations/provider-oauth.ts +777 -0
  70. package/src/integrations/slack-bot.ts +31 -2
  71. package/src/integrations/slack-interactions.ts +487 -27
  72. package/src/integrations/social-api.ts +11 -0
  73. package/src/mcp/documents.ts +32 -1
  74. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  75. package/src/mcp/editable-artifacts.ts +448 -0
  76. package/src/mcp/scheduled-task-view.ts +34 -0
  77. package/src/mcp/server.ts +923 -145
  78. package/src/memory-slack-delivery.ts +209 -0
  79. package/src/routes/api-integrations.ts +407 -0
  80. package/src/routes/browser-identities.ts +136 -0
  81. package/src/routes/browser-sessions.ts +2543 -0
  82. package/src/routes/company-profile.ts +255 -0
  83. package/src/routes/computer-sessions.ts +1247 -0
  84. package/src/routes/connections.ts +284 -99
  85. package/src/routes/documents.ts +22 -3
  86. package/src/routes/editable-artifacts.ts +1159 -0
  87. package/src/routes/files.ts +106 -4
  88. package/src/routes/github.ts +18 -2
  89. package/src/routes/install.ts +7 -1
  90. package/src/routes/integration-features.ts +258 -0
  91. package/src/routes/memory-slack-publications.ts +216 -0
  92. package/src/routes/packs.ts +437 -7
  93. package/src/routes/plugins.ts +751 -0
  94. package/src/routes/rigs.ts +77 -20
  95. package/src/routes/scheduled-tasks.ts +67 -40
  96. package/src/routes/sessions.ts +421 -245
  97. package/src/routes/skills.ts +174 -0
  98. package/src/routes/video-generation.ts +132 -0
  99. package/src/routes/workspaces.ts +46 -24
  100. package/src/sandbox/channel-a.ts +723 -116
  101. package/src/sandbox/metrics-ingestion.ts +121 -3
  102. package/src/sandbox/rematerialize.ts +35 -47
  103. package/src/sandbox/viewer.ts +29 -9
  104. package/src/temporal-schedule-cleanup.ts +135 -0
  105. package/dist/chunk-T4T2PGU4.js.map +0 -1
  106. package/dist/mcp/toolspace.d.ts +0 -71
  107. package/src/mcp/toolspace.ts +0 -1190
@@ -21,7 +21,7 @@ import {
21
21
  applyGitAuthPointerEnvironment,
22
22
  hasGitCredentialRepositorySelection,
23
23
  hasGitHubRepositorySelection,
24
- sandboxArchiveCaptureTimeoutMs,
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 { sandboxOperationMetricObserver, type Observability } from "@opengeni/observability";
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
- toolspaceTokenFileFromEnvironment,
60
- withToolspaceTokenSession,
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
- const CHANNEL_A_HANDLE_CACHE_TTL_MS = 300_000;
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 CachedEstablishedHandle = {
186
+ type CachedReadHandle = {
110
187
  promise: Promise<EstablishedSandboxSession>;
111
- lastUsedAt: number;
188
+ lastUsedAtMonotonicMs: number;
112
189
  };
113
- const establishedHandleCache = new Map<string, CachedEstablishedHandle>();
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 pruneEstablishedHandleCache(now: number): void {
124
- for (const [key, entry] of establishedHandleCache) {
125
- if (now - entry.lastUsedAt > CHANNEL_A_HANDLE_CACHE_TTL_MS) {
126
- establishedHandleCache.delete(key);
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
- while (establishedHandleCache.size >= CHANNEL_A_HANDLE_CACHE_MAX_ENTRIES) {
130
- const oldestKey = establishedHandleCache.keys().next().value as string | undefined;
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
- establishedHandleCache.delete(oldestKey);
242
+ cache.delete(oldestKey);
133
243
  }
134
244
  }
135
245
 
136
- async function establishCachedHandle(
246
+ async function establishCachedReadHandle(
137
247
  key: string,
138
248
  establish: () => Promise<EstablishedSandboxSession>,
139
249
  ): Promise<EstablishedSandboxSession> {
140
- const now = Date.now();
141
- pruneEstablishedHandleCache(now);
142
- const cached = establishedHandleCache.get(key);
250
+ const now = performance.now();
251
+ pruneEstablishedReadHandleCache(now);
252
+ const cached = establishedReadHandleCache.get(key);
143
253
  if (cached) {
144
- cached.lastUsedAt = now;
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
- establishedHandleCache.delete(key);
148
- establishedHandleCache.set(key, cached);
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: CachedEstablishedHandle = { promise, lastUsedAt: now };
154
- establishedHandleCache.set(key, entry);
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 (establishedHandleCache.get(key) === entry) establishedHandleCache.delete(key);
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 establishCachedHandle(
311
+ return await establishCachedReadHandle(
173
312
  establishedHandleCacheKey(workspaceId, sessionId, lease),
174
313
  establish,
175
314
  );
176
315
  }
177
316
 
178
- function rememberEstablishedHandle(key: string, established: EstablishedSandboxSession): void {
179
- pruneEstablishedHandleCache(Date.now());
180
- establishedHandleCache.set(key, {
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
- lastUsedAt: Date.now(),
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 Toolspace pointer selected below.
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.OPENGENI_TOOLSPACE_TOKEN_FILE
255
- ? withToolspaceTokenSession(
539
+ const scopedSession = environment.OPENGENI_CODEMODE_TOKEN_FILE
540
+ ? withCodemodeTokenSession(
256
541
  credentialSession,
257
- toolspaceTokenFileFromEnvironment(environment, session.id),
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
- { db, settings, bus, ...(onSandboxOperation ? { onSandboxOperation } : {}) },
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
- throw mapChannelAError(error);
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 cold.
355
- const acquired = await acquireLease(db, {
356
- accountId,
357
- workspaceId,
358
- sandboxGroupId,
359
- kind: "direct",
360
- holderId,
361
- subjectId: session.id,
362
- backend: session.sandboxBackend,
363
- os: session.sandboxOs,
364
- leaseTtlMs,
365
- warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
366
- captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
367
- });
368
-
369
- if (acquired.role === "blocked") {
370
- await release();
371
- throw new HTTPException(409, {
372
- message: `sandbox recovery ${acquired.lease.recovery.restore.status} at epoch ${acquired.lease.leaseEpoch}`,
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
- if (acquired.role === "fenced") {
376
- await release();
377
- throw new HTTPException(409, {
378
- message: `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); retry`,
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
- rememberEstablishedHandle(establishedCacheKey, established);
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
- establishedCacheKey = establishedHandleCacheKey(workspaceId, session.id, live);
440
- try {
441
- established = await establishCachedChannelAHandle(workspaceId, session.id, live, () =>
442
- establishSandboxSessionFromEnvelope(settings, live.resumeState, {
443
- sessionId: session.id,
444
- recovery: "resume-only",
445
- backendOverride: session.sandboxBackend,
446
- environment,
447
- }),
448
- );
449
- } catch (error) {
450
- if (!isProviderSandboxNotFoundError(session.sandboxBackend, error)) {
451
- throw error;
452
- }
453
- establishedHandleCache.delete(establishedCacheKey);
454
- const marked = await markWarmLeaseInstanceLost(db, {
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
- sandboxGroupId,
458
- expectedEpoch: live.leaseEpoch,
459
- expectedInstanceId: live.instanceId,
460
- });
461
- if (marked.status === "marked") {
462
- await appendAndPublishEvents(db, bus, workspaceId, session.id, [
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
- type: "sandbox.box.lost",
465
- payload: { sandboxId: live.instanceId },
858
+ workspaceId,
859
+ sandboxGroupId,
860
+ leaseEpoch: leaseSnapshot.leaseEpoch,
861
+ instanceId: leaseSnapshot.instanceId!,
466
862
  },
467
- ]);
468
- }
469
- throw new HTTPException(409, {
470
- message: `sandbox instance was lost; retry to restore it`,
471
- });
472
- }
473
- }
863
+ ctx.waitSignal,
864
+ run,
865
+ )
866
+ : await run();
867
+ };
474
868
 
475
- // Route every call through the same proxy, even when hot-swap is disabled:
476
- // routing may be dormant, but its direct mutation admission is mandatory for
477
- // every persistable provider write.
478
- const routed = wrapChannelABoxWithRouting(
479
- { db, settings, bus, ...(onSandboxOperation ? { onSandboxOperation } : {}) },
480
- {
481
- accountId,
482
- workspaceId,
483
- sessionId: session.id,
484
- homeLease: {
485
- sandboxGroupId,
486
- leaseEpoch: leaseSnapshot.leaseEpoch,
487
- instanceId: leaseSnapshot.instanceId!,
488
- backend: session.sandboxBackend,
489
- },
490
- directRequest: { requestId, holderId },
491
- },
492
- established,
493
- );
494
- return await runEstablished(routed, leaseSnapshot);
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
- throw mapChannelAError(error);
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
  //