@opengeni/api-router 0.22.2 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +0 -30
  4. package/dist/browser-controller-authority.d.ts +43 -0
  5. package/dist/browser-state-authority.d.ts +27 -0
  6. package/dist/{chunk-HWXJW5C7.js → chunk-JIKNR5YL.js} +27993 -14546
  7. package/dist/chunk-JIKNR5YL.js.map +1 -0
  8. package/dist/codemode.d.ts +23 -0
  9. package/dist/editable-artifact-live-hints.d.ts +11 -0
  10. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  11. package/dist/editable-artifact-office-import.d.ts +22 -0
  12. package/dist/editable-artifact-production.d.ts +29 -0
  13. package/dist/editable-artifact-websocket.d.ts +49 -0
  14. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  15. package/dist/github-browser-flow.d.ts +6 -0
  16. package/dist/http/cors.d.ts +1 -0
  17. package/dist/http/sse.d.ts +2 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.js +1824 -30
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/api-integrations.d.ts +24 -0
  22. package/dist/integrations/atlassian.d.ts +176 -0
  23. package/dist/integrations/github-skill-source.d.ts +5 -0
  24. package/dist/integrations/google-drive.d.ts +85 -0
  25. package/dist/integrations/oauth-client.d.ts +30 -1
  26. package/dist/integrations/provider-oauth.d.ts +19 -0
  27. package/dist/integrations/slack-bot.d.ts +4 -0
  28. package/dist/integrations/slack-interactions.d.ts +14 -2
  29. package/dist/integrations/social-api.d.ts +2 -1
  30. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  31. package/dist/mcp/editable-artifacts.d.ts +13 -0
  32. package/dist/mcp/receipts.d.ts +28 -0
  33. package/dist/mcp/scheduled-task-view.d.ts +518 -0
  34. package/dist/mcp/server.d.ts +14 -3
  35. package/dist/memory-slack-delivery.d.ts +9 -0
  36. package/dist/routes/api-integrations.d.ts +8 -0
  37. package/dist/routes/browser-identities.d.ts +5 -0
  38. package/dist/routes/browser-sessions.d.ts +6 -0
  39. package/dist/routes/company-profile.d.ts +3 -0
  40. package/dist/routes/computer-sessions.d.ts +6 -0
  41. package/dist/routes/editable-artifacts.d.ts +44 -0
  42. package/dist/routes/integration-features.d.ts +3 -0
  43. package/dist/routes/memory-slack-publications.d.ts +6 -0
  44. package/dist/routes/plugins.d.ts +8 -0
  45. package/dist/routes/sessions.d.ts +17 -2
  46. package/dist/routes/skills.d.ts +6 -0
  47. package/dist/routes/video-generation.d.ts +3 -0
  48. package/dist/sandbox/auth-callout.d.ts +2 -0
  49. package/dist/sandbox/channel-a.d.ts +59 -2
  50. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  51. package/dist/sandbox/viewer.d.ts +4 -2
  52. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  53. package/package.json +19 -14
  54. package/src/app.ts +277 -41
  55. package/src/auth/managed-auth.ts +0 -16
  56. package/src/browser-controller-authority.ts +137 -0
  57. package/src/browser-state-authority.ts +236 -0
  58. package/src/codemode.ts +186 -0
  59. package/src/editable-artifact-live-hints.ts +64 -0
  60. package/src/editable-artifact-native-kernel.ts +659 -0
  61. package/src/editable-artifact-office-import.ts +230 -0
  62. package/src/editable-artifact-production.ts +419 -0
  63. package/src/editable-artifact-websocket.ts +311 -0
  64. package/src/editable-artifact-workspace-files.ts +186 -0
  65. package/src/github-browser-flow.ts +35 -6
  66. package/src/http/auth.ts +2 -0
  67. package/src/http/cors.ts +3 -0
  68. package/src/http/sse.ts +101 -6
  69. package/src/index.ts +147 -23
  70. package/src/integrations/api-integrations.ts +350 -0
  71. package/src/integrations/atlassian.ts +1621 -0
  72. package/src/integrations/github-skill-source.ts +142 -0
  73. package/src/integrations/google-drive.ts +1000 -64
  74. package/src/integrations/oauth-client.ts +159 -89
  75. package/src/integrations/provider-oauth.ts +777 -0
  76. package/src/integrations/slack-bot.ts +31 -2
  77. package/src/integrations/slack-interactions.ts +610 -42
  78. package/src/integrations/social-api.ts +11 -0
  79. package/src/mcp/documents.ts +74 -26
  80. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  81. package/src/mcp/editable-artifacts.ts +448 -0
  82. package/src/mcp/receipts.ts +95 -0
  83. package/src/mcp/scheduled-task-view.ts +642 -0
  84. package/src/mcp/server.ts +1718 -310
  85. package/src/memory-slack-delivery.ts +209 -0
  86. package/src/observability.ts +3 -3
  87. package/src/routes/api-integrations.ts +407 -0
  88. package/src/routes/api-keys.ts +7 -1
  89. package/src/routes/browser-identities.ts +136 -0
  90. package/src/routes/browser-sessions.ts +2543 -0
  91. package/src/routes/codex.ts +7 -4
  92. package/src/routes/company-profile.ts +255 -0
  93. package/src/routes/computer-sessions.ts +1247 -0
  94. package/src/routes/connections.ts +358 -102
  95. package/src/routes/documents.ts +22 -3
  96. package/src/routes/editable-artifacts.ts +1159 -0
  97. package/src/routes/enrollments.ts +54 -12
  98. package/src/routes/environments.ts +60 -11
  99. package/src/routes/files.ts +277 -65
  100. package/src/routes/github.ts +18 -2
  101. package/src/routes/install.ts +38 -2
  102. package/src/routes/integration-features.ts +258 -0
  103. package/src/routes/machines.ts +1 -1
  104. package/src/routes/memory-slack-publications.ts +216 -0
  105. package/src/routes/packs.ts +437 -7
  106. package/src/routes/plugins.ts +751 -0
  107. package/src/routes/rigs.ts +77 -20
  108. package/src/routes/scheduled-tasks.ts +94 -42
  109. package/src/routes/sessions.ts +475 -234
  110. package/src/routes/skills.ts +174 -0
  111. package/src/routes/transcription-recordings.ts +65 -33
  112. package/src/routes/video-generation.ts +132 -0
  113. package/src/routes/workspaces.ts +46 -24
  114. package/src/sandbox/auth-callout.ts +16 -4
  115. package/src/sandbox/channel-a.ts +809 -85
  116. package/src/sandbox/enrollment.ts +13 -3
  117. package/src/sandbox/machines.ts +1 -1
  118. package/src/sandbox/metrics-ingestion.ts +121 -3
  119. package/src/sandbox/rematerialize.ts +35 -47
  120. package/src/sandbox/viewer.ts +58 -29
  121. package/src/temporal-schedule-cleanup.ts +135 -0
  122. package/dist/chunk-HWXJW5C7.js.map +0 -1
  123. package/dist/mcp/toolspace.d.ts +0 -62
  124. package/src/mcp/toolspace.ts +0 -1186
@@ -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";
@@ -30,17 +30,24 @@ import type { Session } from "@opengeni/contracts";
30
30
  import {
31
31
  acquireLease,
32
32
  getSandboxSessionEnvelope,
33
+ getEnrollment,
33
34
  getSandbox,
34
35
  loadWorkspaceEnvironmentForRun,
35
36
  markWarmLeaseInstanceLost,
36
37
  readActiveSandbox,
37
38
  readLease,
38
39
  releaseLeaseHolder,
40
+ SandboxProviderReadLockUnavailableError,
41
+ withSandboxProviderReadLock,
39
42
  type Database,
40
43
  type LeaseSnapshot,
41
44
  } from "@opengeni/db";
42
45
  import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
43
- import { sandboxOperationMetricObserver, type Observability } from "@opengeni/observability";
46
+ import {
47
+ sandboxLeaseTelemetryKey,
48
+ sandboxOperationMetricObserver,
49
+ type Observability,
50
+ } from "@opengeni/observability";
44
51
  import { HTTPException } from "hono/http-exception";
45
52
 
46
53
  import {
@@ -49,13 +56,16 @@ import {
49
56
  isProviderSandboxNotFoundError,
50
57
  SandboxChannelAService,
51
58
  NatsControlRpc,
59
+ NatsOpStreamTransport,
60
+ SandboxResumeIdentityMismatchError,
61
+ SandboxResumeIdentityUnavailableError,
52
62
  ChannelAConflictError,
53
63
  ChannelANotFoundError,
54
64
  ChannelAUnsupportedError,
55
65
  ChannelAUnavailableError,
56
66
  ChannelAValidationError,
57
- toolspaceTokenFileFromEnvironment,
58
- withToolspaceTokenSession,
67
+ codemodeTokenFileFromEnvironment,
68
+ withCodemodeTokenSession,
59
69
  withRunCredentialsSession,
60
70
  type ChannelASession,
61
71
  type EstablishedSandboxSession,
@@ -71,12 +81,66 @@ export type ChannelAServices = {
71
81
  observability?: Observability | undefined;
72
82
  };
73
83
 
84
+ export type ChannelAOperation =
85
+ | "fs.list"
86
+ | "fs.list-batch"
87
+ | "fs.read"
88
+ | "fs.write"
89
+ | "fs.delete"
90
+ | "fs.move"
91
+ | "fs.mkdir"
92
+ | "git.status"
93
+ | "git.diff"
94
+ | "git.read-batch"
95
+ | "git.log"
96
+ | "git.show"
97
+ | "terminal.exec"
98
+ | "terminal.pty.open"
99
+ | "terminal.pty.write"
100
+ | "terminal.pty.resize"
101
+ | "terminal.pty.close"
102
+ | "browser.create"
103
+ | "browser.resume"
104
+ | "browser.suspend"
105
+ | "browser.end"
106
+ | "browser.read"
107
+ | "browser.control"
108
+ | "browser.attach"
109
+ | "computer.create"
110
+ | "computer.end"
111
+ | "computer.read"
112
+ | "computer.control"
113
+ | "computer.attach";
114
+
74
115
  export type ChannelAContext = {
75
116
  accountId: string;
76
117
  workspaceId: string;
77
118
  session: Session;
78
119
  // The principal that drives the op (for emit attribution + pty opened_by).
79
120
  subjectId: string;
121
+ /** Cancel lifecycle waiting when the originating HTTP request disconnects. */
122
+ waitSignal?: AbortSignal | undefined;
123
+ /** Bounded route identity for metrics and safe operator diagnostics. */
124
+ operation?: ChannelAOperation | undefined;
125
+ };
126
+
127
+ export type ChannelAOperationFailureReason =
128
+ | "request_cancelled"
129
+ | "provider_read_busy"
130
+ | "provider_unavailable"
131
+ | "lifecycle_conflict"
132
+ | "request_rejected"
133
+ | "unexpected";
134
+
135
+ export type ChannelAOperationFailureDiagnostic = {
136
+ reason: ChannelAOperationFailureReason;
137
+ status: number;
138
+ errorCode:
139
+ | "sandbox_channel_a_cancelled"
140
+ | "sandbox_channel_a_provider_busy"
141
+ | "sandbox_channel_a_provider_unavailable"
142
+ | "sandbox_channel_a_lifecycle_conflict"
143
+ | "sandbox_channel_a_operation_failed";
80
144
  };
81
145
 
82
146
  // The live op surface handed to a route's callback: the service + the live lease
@@ -86,10 +150,299 @@ export type ChannelAHandle = {
86
150
  /** Connected Machine homes deliberately have no cloud lease. Durable PTYs
87
151
  * require a real home-provider lease and reject this null case. */
88
152
  lease: LeaseSnapshot | null;
153
+ /** Exact placement-home session established under this request's lease or
154
+ * Connected Machine fence. Unlike routingSession, this never follows a later
155
+ * active-sandbox pointer and is safe for placement-bound controllers. */
156
+ homeSession: ChannelASession;
89
157
  routingSession: RoutingSandboxSession;
90
158
  requestId: string;
91
159
  };
92
160
 
161
+ /**
162
+ * Provider handles are lightweight references to a lease-owned sandbox, but
163
+ * reconstructing one is not free: Modal resume-by-id plus its first command can
164
+ * dominate a small Git/files read. Workspace panels issue several independent
165
+ * Channel-A requests together, so reuse the exact fenced handle briefly instead
166
+ * of making every request reattach to the same warm instance.
167
+ *
168
+ * The key includes the session, lease epoch, and immutable provider instance id.
169
+ * A rotation can therefore never inherit an old handle. Entries are bounded and
170
+ * expire opportunistically; eviction only drops local references and never
171
+ * terminates the lease-owned sandbox.
172
+ */
173
+ // Read/viewer handles and process-capable handles deliberately have separate
174
+ // caches. The pinned Modal patch rotates a handle's command-router transport in
175
+ // place, while a typed read failure below still gets one fresh-handle fallback.
176
+ // Periodically rebuilding a healthy hot read handle would add multi-second
177
+ // stalls, so both caches keep the pre-existing five-minute IDLE lifetime.
178
+ //
179
+ // Modal and Unix/Docker SDK sessions retain yielded exec/PTY process objects in
180
+ // a process-local map; rebuilding the wrapper cannot reconstruct those objects
181
+ // from the numeric provider session id. Reads therefore never enter or evict
182
+ // the process cache merely to refresh their own transport.
183
+ const CHANNEL_A_READ_HANDLE_CACHE_IDLE_TTL_MS = 5 * 60_000;
184
+ const CHANNEL_A_PROCESS_HANDLE_CACHE_IDLE_TTL_MS = 5 * 60_000;
185
+ const CHANNEL_A_HANDLE_CACHE_MAX_ENTRIES = 64;
186
+ type CachedReadHandle = {
187
+ promise: Promise<EstablishedSandboxSession>;
188
+ lastUsedAtMonotonicMs: number;
189
+ };
190
+ type CachedProcessHandle = {
191
+ promise: Promise<EstablishedSandboxSession>;
192
+ lastUsedAtMonotonicMs: number;
193
+ };
194
+ export type EstablishedHandleCacheKind = "read" | "process" | "none";
195
+ const establishedReadHandleCache = new Map<string, CachedReadHandle>();
196
+ const establishedProcessHandleCache = new Map<string, CachedProcessHandle>();
197
+
198
+ function establishedHandleCacheKey(
199
+ workspaceId: string,
200
+ sessionId: string,
201
+ lease: LeaseSnapshot,
202
+ ): string {
203
+ return [workspaceId, sessionId, lease.leaseEpoch, lease.instanceId ?? ""].join("\u0000");
204
+ }
205
+
206
+ export function isChannelAHandleCacheEntryFresh(
207
+ lastUsedAtMonotonicMs: number,
208
+ nowMonotonicMs: number,
209
+ idleTtlMs = CHANNEL_A_READ_HANDLE_CACHE_IDLE_TTL_MS,
210
+ ): boolean {
211
+ return nowMonotonicMs - lastUsedAtMonotonicMs < idleTtlMs;
212
+ }
213
+
214
+ export function isChannelAProcessHandleCacheEntryFresh(
215
+ lastUsedAtMonotonicMs: number,
216
+ nowMonotonicMs: number,
217
+ idleTtlMs = CHANNEL_A_PROCESS_HANDLE_CACHE_IDLE_TTL_MS,
218
+ ): boolean {
219
+ return nowMonotonicMs - lastUsedAtMonotonicMs < idleTtlMs;
220
+ }
221
+
222
+ function pruneEstablishedReadHandleCache(nowMonotonicMs: number): void {
223
+ for (const [key, entry] of establishedReadHandleCache) {
224
+ if (!isChannelAHandleCacheEntryFresh(entry.lastUsedAtMonotonicMs, nowMonotonicMs)) {
225
+ establishedReadHandleCache.delete(key);
226
+ }
227
+ }
228
+ }
229
+
230
+ function pruneEstablishedProcessHandleCache(nowMonotonicMs: number): void {
231
+ for (const [key, entry] of establishedProcessHandleCache) {
232
+ if (!isChannelAProcessHandleCacheEntryFresh(entry.lastUsedAtMonotonicMs, nowMonotonicMs)) {
233
+ establishedProcessHandleCache.delete(key);
234
+ }
235
+ }
236
+ }
237
+
238
+ function enforceEstablishedHandleCacheSize<T>(cache: Map<string, T>): void {
239
+ while (cache.size > CHANNEL_A_HANDLE_CACHE_MAX_ENTRIES) {
240
+ const oldestKey = cache.keys().next().value as string | undefined;
241
+ if (oldestKey === undefined) break;
242
+ cache.delete(oldestKey);
243
+ }
244
+ }
245
+
246
+ async function establishCachedReadHandle(
247
+ key: string,
248
+ establish: () => Promise<EstablishedSandboxSession>,
249
+ ): Promise<EstablishedSandboxSession> {
250
+ const now = performance.now();
251
+ pruneEstablishedReadHandleCache(now);
252
+ const cached = establishedReadHandleCache.get(key);
253
+ if (cached) {
254
+ cached.lastUsedAtMonotonicMs = now;
255
+ // Refresh insertion order so the bounded map evicts the least-recently used
256
+ // exact lease identity first.
257
+ establishedReadHandleCache.delete(key);
258
+ establishedReadHandleCache.set(key, cached);
259
+ return await cached.promise;
260
+ }
261
+
262
+ const promise = establish();
263
+ const entry: CachedReadHandle = { promise, lastUsedAtMonotonicMs: now };
264
+ establishedReadHandleCache.set(key, entry);
265
+ enforceEstablishedHandleCacheSize(establishedReadHandleCache);
266
+ try {
267
+ return await promise;
268
+ } catch (error) {
269
+ if (establishedReadHandleCache.get(key) === entry) establishedReadHandleCache.delete(key);
270
+ throw error;
271
+ }
272
+ }
273
+
274
+ async function establishCachedProcessHandle(
275
+ key: string,
276
+ establish: () => Promise<EstablishedSandboxSession>,
277
+ ): Promise<EstablishedSandboxSession> {
278
+ const now = performance.now();
279
+ pruneEstablishedProcessHandleCache(now);
280
+ const cached = establishedProcessHandleCache.get(key);
281
+ if (cached) {
282
+ cached.lastUsedAtMonotonicMs = now;
283
+ establishedProcessHandleCache.delete(key);
284
+ establishedProcessHandleCache.set(key, cached);
285
+ return await cached.promise;
286
+ }
287
+
288
+ const promise = establish();
289
+ const entry: CachedProcessHandle = { promise, lastUsedAtMonotonicMs: now };
290
+ establishedProcessHandleCache.set(key, entry);
291
+ enforceEstablishedHandleCacheSize(establishedProcessHandleCache);
292
+ try {
293
+ return await promise;
294
+ } catch (error) {
295
+ if (establishedProcessHandleCache.get(key) === entry) {
296
+ establishedProcessHandleCache.delete(key);
297
+ }
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ /** Reuse the exact lease-fenced provider handle across API-direct surfaces.
303
+ * Stream capability negotiation and the first Files/Changes reads commonly run
304
+ * back-to-back; sharing this handle avoids paying the same Modal resume twice. */
305
+ export async function establishCachedChannelAHandle(
306
+ workspaceId: string,
307
+ sessionId: string,
308
+ lease: LeaseSnapshot,
309
+ establish: () => Promise<EstablishedSandboxSession>,
310
+ ): Promise<EstablishedSandboxSession> {
311
+ return await establishCachedReadHandle(
312
+ establishedHandleCacheKey(workspaceId, sessionId, lease),
313
+ establish,
314
+ );
315
+ }
316
+
317
+ async function establishCachedChannelAProcessHandle(
318
+ workspaceId: string,
319
+ sessionId: string,
320
+ lease: LeaseSnapshot,
321
+ establish: () => Promise<EstablishedSandboxSession>,
322
+ ): Promise<EstablishedSandboxSession> {
323
+ return await establishCachedProcessHandle(
324
+ establishedHandleCacheKey(workspaceId, sessionId, lease),
325
+ establish,
326
+ );
327
+ }
328
+
329
+ /**
330
+ * Run independent, side-effect-free Channel-A reads concurrently without
331
+ * releasing the direct-request holder while sibling provider commands are
332
+ * still settling. A typed temporary-unavailable failure is retried exactly
333
+ * once after every first attempt has settled; validation, conflict, not-found,
334
+ * and unknown failures are never replayed.
335
+ */
336
+ export async function runConcurrentChannelAReads<T>(
337
+ operations: readonly (() => Promise<T>)[],
338
+ ): Promise<T[]> {
339
+ const values = new Array<T>(operations.length);
340
+ const first = await Promise.allSettled(
341
+ operations.map((operation) => Promise.resolve().then(operation)),
342
+ );
343
+ const retryIndexes: number[] = [];
344
+
345
+ for (const [index, result] of first.entries()) {
346
+ if (result.status === "fulfilled") {
347
+ values[index] = result.value;
348
+ continue;
349
+ }
350
+ if (!(result.reason instanceof ChannelAUnavailableError)) {
351
+ throw result.reason;
352
+ }
353
+ retryIndexes.push(index);
354
+ }
355
+
356
+ if (retryIndexes.length === 0) return values;
357
+
358
+ const retried = await Promise.allSettled(
359
+ retryIndexes.map((index) => Promise.resolve().then(operations[index]!)),
360
+ );
361
+ for (const [retryIndex, result] of retried.entries()) {
362
+ if (result.status === "rejected") throw result.reason;
363
+ values[retryIndexes[retryIndex]!] = result.value;
364
+ }
365
+ return values;
366
+ }
367
+
368
+ type ChannelAReadRecoveryOptions = {
369
+ /** Modal may expose one more stale command-router route after the first
370
+ * successful handle rebuild. Keep this closed and statically bounded. */
371
+ maxFreshHandleRetries?: 1 | 2;
372
+ /** Never start another provider attempt after the originating request ends. */
373
+ waitSignal?: AbortSignal | undefined;
374
+ };
375
+
376
+ /** Retry a side-effect-free Channel-A read only after the caller has discarded
377
+ * and freshly re-established its provider handle. The ordinary provider-neutral
378
+ * contract allows one retry; Modal opts into one additional rebuild because a
379
+ * command-router rollover can outlive the first replacement handle. Provider
380
+ * commands are never replayed for validation/conflict/unknown errors, mutation
381
+ * routes never call this helper, and request cancellation stops recovery before
382
+ * another provider command begins. */
383
+ export async function runChannelAReadWithFreshHandleRetry<T>(
384
+ run: () => Promise<T>,
385
+ refreshHandle: (attempt: 1 | 2) => Promise<void>,
386
+ options: ChannelAReadRecoveryOptions = {},
387
+ ): Promise<T> {
388
+ const maxFreshHandleRetries = options.maxFreshHandleRetries ?? 1;
389
+ for (let retries = 0; ; retries += 1) {
390
+ options.waitSignal?.throwIfAborted();
391
+ try {
392
+ return await run();
393
+ } catch (error) {
394
+ if (!(error instanceof ChannelAUnavailableError) || retries >= maxFreshHandleRetries) {
395
+ throw error;
396
+ }
397
+ options.waitSignal?.throwIfAborted();
398
+ const attempt = retries === 0 ? 1 : 2;
399
+ await refreshHandle(attempt);
400
+ }
401
+ }
402
+ }
403
+
404
+ export function shouldEvictChannelAHandleAfterError(
405
+ error: unknown,
406
+ cacheKind: EstablishedHandleCacheKind,
407
+ ): boolean {
408
+ return error instanceof ChannelAUnavailableError && cacheKind === "read";
409
+ }
410
+
411
+ function evictEstablishedHandle(key: string, cacheKind: EstablishedHandleCacheKind): void {
412
+ if (cacheKind === "read") establishedReadHandleCache.delete(key);
413
+ if (cacheKind === "process") establishedProcessHandleCache.delete(key);
414
+ }
415
+
416
+ function evictAllEstablishedHandles(key: string): void {
417
+ establishedReadHandleCache.delete(key);
418
+ establishedProcessHandleCache.delete(key);
419
+ }
420
+
421
+ function rememberEstablishedHandle(
422
+ key: string,
423
+ established: EstablishedSandboxSession,
424
+ cacheKind: Exclude<EstablishedHandleCacheKind, "none">,
425
+ ): void {
426
+ const now = performance.now();
427
+ if (cacheKind === "read") {
428
+ pruneEstablishedReadHandleCache(now);
429
+ establishedReadHandleCache.delete(key);
430
+ establishedReadHandleCache.set(key, {
431
+ promise: Promise.resolve(established),
432
+ lastUsedAtMonotonicMs: now,
433
+ });
434
+ enforceEstablishedHandleCacheSize(establishedReadHandleCache);
435
+ return;
436
+ }
437
+ pruneEstablishedProcessHandleCache(now);
438
+ establishedProcessHandleCache.delete(key);
439
+ establishedProcessHandleCache.set(key, {
440
+ promise: Promise.resolve(established),
441
+ lastUsedAtMonotonicMs: now,
442
+ });
443
+ enforceEstablishedHandleCacheSize(establishedProcessHandleCache);
444
+ }
445
+
93
446
  /**
94
447
  * Run a Channel-A op against a live box, API-direct. Acquires an exact direct holder
95
448
  * (warming the box when cold), resumes by id, builds the service, runs `fn`, and
@@ -103,6 +456,28 @@ export async function withChannelA<T>(
103
456
  services: ChannelAServices,
104
457
  ctx: ChannelAContext,
105
458
  fn: (handle: ChannelAHandle) => Promise<T>,
459
+ ): Promise<T> {
460
+ return await withChannelAOperation(services, ctx, false, fn);
461
+ }
462
+
463
+ /** Read-only API-direct seam. Separate requests for the same exact live Modal
464
+ * instance are serialized across API replicas; each request's batched reads
465
+ * remain concurrent behind that one distributed boundary. A typed temporary
466
+ * provider-channel failure gets one retry only after rebuilding the exact
467
+ * lease-fenced handle. */
468
+ export async function withChannelARead<T>(
469
+ services: ChannelAServices,
470
+ ctx: ChannelAContext,
471
+ fn: (handle: ChannelAHandle) => Promise<T>,
472
+ ): Promise<T> {
473
+ return await withChannelAOperation(services, ctx, true, fn);
474
+ }
475
+
476
+ async function withChannelAOperation<T>(
477
+ services: ChannelAServices,
478
+ ctx: ChannelAContext,
479
+ readOnly: boolean,
480
+ fn: (handle: ChannelAHandle) => Promise<T>,
106
481
  ): Promise<T> {
107
482
  const { db, settings, bus } = services;
108
483
  const onSandboxOperation = services.observability
@@ -117,10 +492,12 @@ export async function withChannelA<T>(
117
492
  const sandboxGroupId = session.sandboxGroupId;
118
493
  const requestId = crypto.randomUUID();
119
494
  const holderId = `direct:${requestId}`;
495
+ const operationStartedAt = performance.now();
496
+ const operation = ctx.operation ?? (readOnly ? "read" : "mutation");
120
497
  const leaseTtlMs = settings.sandboxLeaseTtlMs;
121
498
 
122
499
  // The STABLE run-environment used by both a cloud home and a machine home.
123
- // It also carries the per-session Toolspace pointer selected below.
500
+ // It also carries the per-session Codemode pointer selected below.
124
501
  const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
125
502
  db,
126
503
  settings,
@@ -146,6 +523,7 @@ export async function withChannelA<T>(
146
523
  const runEstablished = async (
147
524
  routed: EstablishedSandboxSession,
148
525
  lease: LeaseSnapshot | null,
526
+ homeSession: ChannelASession,
149
527
  ): Promise<T> => {
150
528
  const emit = async (events: { type: string; payload: unknown }[]): Promise<void> => {
151
529
  await appendAndPublishEvents(
@@ -158,10 +536,10 @@ export async function withChannelA<T>(
158
536
  };
159
537
  const routingSession = routed.session as RoutingSandboxSession;
160
538
  const credentialSession = withRunCredentialsSession(routingSession as object, session.id);
161
- const scopedSession = environment.OPENGENI_TOOLSPACE_TOKEN_FILE
162
- ? withToolspaceTokenSession(
539
+ const scopedSession = environment.OPENGENI_CODEMODE_TOKEN_FILE
540
+ ? withCodemodeTokenSession(
163
541
  credentialSession,
164
- toolspaceTokenFileFromEnvironment(environment, session.id),
542
+ codemodeTokenFileFromEnvironment(environment, session.id),
165
543
  )
166
544
  : credentialSession;
167
545
  const service = new SandboxChannelAService({
@@ -169,7 +547,12 @@ export async function withChannelA<T>(
169
547
  leaseEpoch: lease?.leaseEpoch ?? session.activeEpoch,
170
548
  emit,
171
549
  });
172
- return await fn({ service, lease, routingSession, requestId });
550
+ const result = await fn({ service, lease, homeSession, routingSession, requestId });
551
+ // The direct request has accepted the result in memory. Finalize every
552
+ // Connected Machine backend the routing proxy reached so a mid-request
553
+ // route transition cannot leave completed output retained until TTL.
554
+ await routingSession.finalizeOpStreamOps().catch(() => undefined);
555
+ return result;
173
556
  };
174
557
 
175
558
  // A machine-targeted top-level session has an honest selfhosted HOME label.
@@ -178,6 +561,7 @@ export async function withChannelA<T>(
178
561
  if (session.sandboxBackend === "selfhosted") {
179
562
  let established: EstablishedSandboxSession | undefined;
180
563
  try {
564
+ ctx.waitSignal?.throwIfAborted();
181
565
  const pointer = await readActiveSandbox(db, workspaceId, session.id);
182
566
  if (!pointer?.activeSandboxId) {
183
567
  throw new HTTPException(409, {
@@ -190,6 +574,7 @@ export async function withChannelA<T>(
190
574
  message: "machine-home session points to an unavailable Connected Machine",
191
575
  });
192
576
  }
577
+ const enrollment = await getEnrollment(db, workspaceId, sandbox.enrollmentId);
193
578
  const built = await buildSelfhostedBackendSession({
194
579
  workspaceId,
195
580
  agentId: sandbox.enrollmentId,
@@ -200,6 +585,17 @@ export async function withChannelA<T>(
200
585
  workingDir: pointer.workingDir,
201
586
  timeoutMs: settings.sandboxSelfhostedControlTimeoutMs,
202
587
  execTimeoutMs: settings.sandboxSelfhostedExecTimeoutMs,
588
+ ...(settings.agentOpStreamEnabled === true &&
589
+ enrollment?.opStream === true &&
590
+ bus.getOpStreamConnection
591
+ ? {
592
+ opStream: {
593
+ transport: new NatsOpStreamTransport(
594
+ async () => bus.getOpStreamConnection?.() ?? null,
595
+ ),
596
+ },
597
+ }
598
+ : {}),
203
599
  });
204
600
  established = {
205
601
  client: built.client,
@@ -209,7 +605,13 @@ export async function withChannelA<T>(
209
605
  backendId: "selfhosted",
210
606
  };
211
607
  const routed = wrapChannelABoxWithRouting(
212
- { db, settings, bus, ...(onSandboxOperation ? { onSandboxOperation } : {}) },
608
+ {
609
+ db,
610
+ settings,
611
+ bus,
612
+ ...(onSandboxOperation ? { onSandboxOperation } : {}),
613
+ ...(ctx.waitSignal ? { waitSignal: ctx.waitSignal } : {}),
614
+ },
213
615
  {
214
616
  accountId,
215
617
  workspaceId,
@@ -222,9 +624,18 @@ export async function withChannelA<T>(
222
624
  },
223
625
  established,
224
626
  );
225
- return await runEstablished(routed, null);
627
+ return await runEstablished(routed, null, established.session as ChannelASession);
226
628
  } catch (error) {
227
- 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);
228
639
  } finally {
229
640
  await dropEstablishedHandle(established);
230
641
  }
@@ -241,36 +652,116 @@ export async function withChannelA<T>(
241
652
  });
242
653
  };
243
654
 
244
- // Acquire exact request authority; the cold->warming CAS spawns the box when cold.
245
- const acquired = await acquireLease(db, {
246
- accountId,
247
- workspaceId,
248
- sandboxGroupId,
249
- kind: "direct",
250
- holderId,
251
- subjectId: session.id,
252
- backend: session.sandboxBackend,
253
- os: session.sandboxOs,
254
- leaseTtlMs,
255
- warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
256
- captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
257
- });
258
-
259
- if (acquired.role === "blocked") {
260
- await release();
261
- throw new HTTPException(409, {
262
- 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 } : {}),
263
676
  });
264
- }
265
- if (acquired.role === "fenced") {
266
- await release();
267
- throw new HTTPException(409, {
268
- 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,
269
708
  });
709
+ throw mapChannelAError(error, ctx.waitSignal);
270
710
  }
271
711
 
272
712
  let established: EstablishedSandboxSession | undefined;
273
713
  let leaseSnapshot: LeaseSnapshot = acquired.lease;
714
+ let establishedCacheKey: string | null = null;
715
+ let establishedCacheKind: EstablishedHandleCacheKind = "none";
716
+ const requestedCacheKind: Exclude<EstablishedHandleCacheKind, "none"> = readOnly
717
+ ? "read"
718
+ : "process";
719
+
720
+ const establishAttachedLiveHandle = async (
721
+ live: LeaseSnapshot,
722
+ cacheKind: EstablishedHandleCacheKind,
723
+ ): Promise<{ established: EstablishedSandboxSession; cacheKey: string }> => {
724
+ const cacheKey = establishedHandleCacheKey(workspaceId, session.id, live);
725
+ const establish = () =>
726
+ establishSandboxSessionFromEnvelope(settings, live.resumeState, {
727
+ sessionId: session.id,
728
+ recovery: "resume-only",
729
+ backendOverride: session.sandboxBackend,
730
+ environment,
731
+ });
732
+ try {
733
+ const attached =
734
+ cacheKind === "read"
735
+ ? await establishCachedChannelAHandle(workspaceId, session.id, live, establish)
736
+ : cacheKind === "process"
737
+ ? await establishCachedChannelAProcessHandle(workspaceId, session.id, live, establish)
738
+ : await establish();
739
+ return { established: attached, cacheKey };
740
+ } catch (error) {
741
+ if (!isProviderSandboxNotFoundError(session.sandboxBackend, error)) throw error;
742
+ // The exact provider instance is definitively gone, so neither a read nor
743
+ // a process wrapper for that lease identity may survive locally.
744
+ evictAllEstablishedHandles(cacheKey);
745
+ const marked = await markWarmLeaseInstanceLost(db, {
746
+ accountId,
747
+ workspaceId,
748
+ sandboxGroupId,
749
+ expectedEpoch: live.leaseEpoch,
750
+ expectedInstanceId: live.instanceId!,
751
+ });
752
+ if (marked.status === "marked") {
753
+ await appendAndPublishEvents(db, bus, workspaceId, session.id, [
754
+ {
755
+ type: "sandbox.box.lost",
756
+ payload: { sandboxId: live.instanceId },
757
+ },
758
+ ]);
759
+ }
760
+ throw new HTTPException(409, {
761
+ message: `sandbox instance was lost; retry to restore it`,
762
+ });
763
+ }
764
+ };
274
765
 
275
766
  try {
276
767
  const envelope = await getSandboxSessionEnvelope(db, workspaceId, session.id);
@@ -303,6 +794,9 @@ export async function withChannelA<T>(
303
794
  });
304
795
  established = result.established;
305
796
  leaseSnapshot = result.lease;
797
+ establishedCacheKey = establishedHandleCacheKey(workspaceId, session.id, leaseSnapshot);
798
+ establishedCacheKind = requestedCacheKind;
799
+ rememberEstablishedHandle(establishedCacheKey, established, establishedCacheKind);
306
800
  } catch (error) {
307
801
  throw new HTTPException(409, {
308
802
  message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})`,
@@ -323,60 +817,151 @@ export async function withChannelA<T>(
323
817
  });
324
818
  }
325
819
  leaseSnapshot = live;
326
- try {
327
- established = await establishSandboxSessionFromEnvelope(settings, live.resumeState, {
328
- sessionId: session.id,
329
- recovery: "resume-only",
330
- backendOverride: session.sandboxBackend,
331
- environment,
332
- });
333
- } catch (error) {
334
- if (!isProviderSandboxNotFoundError(session.sandboxBackend, error)) {
335
- throw error;
336
- }
337
- 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
+ {
338
839
  accountId,
339
840
  workspaceId,
340
- sandboxGroupId,
341
- expectedEpoch: live.leaseEpoch,
342
- expectedInstanceId: live.instanceId,
343
- });
344
- if (marked.status === "marked") {
345
- 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,
346
857
  {
347
- type: "sandbox.box.lost",
348
- payload: { sandboxId: live.instanceId },
858
+ workspaceId,
859
+ sandboxGroupId,
860
+ leaseEpoch: leaseSnapshot.leaseEpoch,
861
+ instanceId: leaseSnapshot.instanceId!,
349
862
  },
350
- ]);
351
- }
352
- throw new HTTPException(409, {
353
- message: `sandbox instance was lost; retry to restore it`,
354
- });
355
- }
356
- }
863
+ ctx.waitSignal,
864
+ run,
865
+ )
866
+ : await run();
867
+ };
357
868
 
358
- // Route every call through the same proxy, even when hot-swap is disabled:
359
- // routing may be dormant, but its direct mutation admission is mandatory for
360
- // every persistable provider write.
361
- const routed = wrapChannelABoxWithRouting(
362
- { db, settings, bus, ...(onSandboxOperation ? { onSandboxOperation } : {}) },
363
- {
364
- accountId,
365
- workspaceId,
366
- sessionId: session.id,
367
- homeLease: {
368
- sandboxGroupId,
369
- leaseEpoch: leaseSnapshot.leaseEpoch,
370
- instanceId: leaseSnapshot.instanceId!,
371
- backend: session.sandboxBackend,
372
- },
373
- directRequest: { requestId, holderId },
374
- },
375
- established,
376
- );
377
- 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();
378
947
  } catch (error) {
379
- 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);
380
965
  } finally {
381
966
  await release();
382
967
  await dropEstablishedHandle(established);
@@ -385,8 +970,20 @@ export async function withChannelA<T>(
385
970
 
386
971
  /** Map the service's typed errors to HTTP status (the §5.3 matrix). Re-throws an
387
972
  * already-HTTPException unchanged. */
388
- export function mapChannelAError(error: unknown): unknown {
973
+ export function mapChannelAError(error: unknown, waitSignal?: AbortSignal): unknown {
389
974
  if (error instanceof HTTPException) return error;
975
+ if (isChannelARequestCancellation(error, waitSignal))
976
+ return new HTTPException(499 as never, {
977
+ message: "request cancelled",
978
+ cause: error,
979
+ });
980
+ if (
981
+ error instanceof SandboxResumeIdentityMismatchError ||
982
+ error instanceof SandboxResumeIdentityUnavailableError
983
+ )
984
+ return new HTTPException(409, { message: error.message });
985
+ if (error instanceof SandboxProviderReadLockUnavailableError)
986
+ return new HTTPException(503, { message: error.message });
390
987
  if (error instanceof ChannelAUnavailableError)
391
988
  return new HTTPException(503, { message: error.message });
392
989
  if (error instanceof ChannelAValidationError)
@@ -400,6 +997,133 @@ export function mapChannelAError(error: unknown): unknown {
400
997
  return error;
401
998
  }
402
999
 
1000
+ export function isChannelARequestCancellation(error: unknown, waitSignal?: AbortSignal): boolean {
1001
+ if (waitSignal?.aborted !== true) return false;
1002
+ const isSignalReason = waitSignal.reason !== undefined && error === waitSignal.reason;
1003
+ const isAbortError =
1004
+ (error instanceof DOMException && error.name === "AbortError") ||
1005
+ (error instanceof Error && error.name === "AbortError");
1006
+ return isSignalReason || isAbortError;
1007
+ }
1008
+
1009
+ /** Structural classification only: exact provider exception text, codes, URLs,
1010
+ * and identifiers never cross the telemetry boundary. */
1011
+ export function channelAOperationFailureDiagnostic(
1012
+ error: unknown,
1013
+ waitSignal?: AbortSignal,
1014
+ ): ChannelAOperationFailureDiagnostic {
1015
+ if (isChannelARequestCancellation(error, waitSignal)) {
1016
+ return {
1017
+ reason: "request_cancelled",
1018
+ status: 499,
1019
+ errorCode: "sandbox_channel_a_cancelled",
1020
+ };
1021
+ }
1022
+ if (error instanceof SandboxProviderReadLockUnavailableError) {
1023
+ return {
1024
+ reason: "provider_read_busy",
1025
+ status: 503,
1026
+ errorCode: "sandbox_channel_a_provider_busy",
1027
+ };
1028
+ }
1029
+ if (error instanceof ChannelAUnavailableError) {
1030
+ return {
1031
+ reason: "provider_unavailable",
1032
+ status: 503,
1033
+ errorCode: "sandbox_channel_a_provider_unavailable",
1034
+ };
1035
+ }
1036
+ if (
1037
+ error instanceof SandboxResumeIdentityMismatchError ||
1038
+ error instanceof SandboxResumeIdentityUnavailableError ||
1039
+ (error instanceof HTTPException && error.status === 409)
1040
+ ) {
1041
+ return {
1042
+ reason: "lifecycle_conflict",
1043
+ status: 409,
1044
+ errorCode: "sandbox_channel_a_lifecycle_conflict",
1045
+ };
1046
+ }
1047
+ if (
1048
+ error instanceof ChannelAValidationError ||
1049
+ error instanceof ChannelANotFoundError ||
1050
+ error instanceof ChannelAConflictError ||
1051
+ error instanceof ChannelAUnsupportedError
1052
+ ) {
1053
+ const mapped = mapChannelAError(error, waitSignal);
1054
+ return {
1055
+ reason: "request_rejected",
1056
+ status: mapped instanceof HTTPException ? mapped.status : 500,
1057
+ errorCode: "sandbox_channel_a_operation_failed",
1058
+ };
1059
+ }
1060
+ if (error instanceof HTTPException) {
1061
+ return {
1062
+ reason: error.status >= 500 ? "unexpected" : "request_rejected",
1063
+ status: error.status,
1064
+ errorCode: "sandbox_channel_a_operation_failed",
1065
+ };
1066
+ }
1067
+ return {
1068
+ reason: "unexpected",
1069
+ status: 500,
1070
+ errorCode: "sandbox_channel_a_operation_failed",
1071
+ };
1072
+ }
1073
+
1074
+ function observeChannelAOperationFailure(
1075
+ services: ChannelAServices,
1076
+ input: {
1077
+ workspaceId: string;
1078
+ sandboxGroupId: string;
1079
+ backend: string;
1080
+ operation: string;
1081
+ durationMs: number;
1082
+ error: unknown;
1083
+ waitSignal?: AbortSignal | undefined;
1084
+ },
1085
+ ): void {
1086
+ if (!services.observability) return;
1087
+ const diagnostic = channelAOperationFailureDiagnostic(input.error, input.waitSignal);
1088
+ const attributes = {
1089
+ sandboxLeaseKey: sandboxLeaseTelemetryKey(input.workspaceId, input.sandboxGroupId),
1090
+ backend: input.backend,
1091
+ op: input.operation,
1092
+ outcome: "failed",
1093
+ reason: diagnostic.reason,
1094
+ status: diagnostic.status,
1095
+ durationMs: Math.max(0, Math.round(input.durationMs)),
1096
+ errorClass: "SandboxChannelAOperationError",
1097
+ errorCode: diagnostic.errorCode,
1098
+ origin: "api",
1099
+ } as const;
1100
+ try {
1101
+ services.observability.incrementCounter({
1102
+ name: "opengeni_channel_a_operation_failures_total",
1103
+ help: "Channel-A failures by bounded operation and structural reason.",
1104
+ labels: {
1105
+ backend: input.backend,
1106
+ op: input.operation,
1107
+ reason: diagnostic.reason,
1108
+ status: String(diagnostic.status),
1109
+ },
1110
+ });
1111
+ } catch {
1112
+ // Metrics can never alter request or lease settlement.
1113
+ }
1114
+ try {
1115
+ if (diagnostic.reason === "request_cancelled") {
1116
+ services.observability.info("Channel-A request cancelled", attributes);
1117
+ } else if (diagnostic.reason === "request_rejected") {
1118
+ services.observability.debug("Channel-A request rejected", attributes);
1119
+ } else {
1120
+ services.observability.warn("Channel-A operation failed", attributes);
1121
+ }
1122
+ } catch {
1123
+ // Logs can never alter request or lease settlement.
1124
+ }
1125
+ }
1126
+
403
1127
  // Drop a transiently-established, NON-OWNED handle WITHOUT terminating the box.
404
1128
  // The box is owned by the LEASE (resumed by id); this handle is incidental.
405
1129
  //