@opengeni/api-router 0.23.1 → 0.30.0

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