@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
@@ -156,8 +156,8 @@ function machineStateFor(
156
156
 
157
157
  /**
158
158
  * Build the Machines dashboard response for a workspace. When `sessionId` is
159
- * supplied (an in-session view) the session's synthetic Modal group box is
160
- * prepended (`isSessionGroup:true`) and the active-sandbox pointer is echoed;
159
+ * supplied (an in-session view) the session's synthetic home group box is
160
+ * prepended when one exists (`isSessionGroup:true`) and the active-sandbox pointer is echoed;
161
161
  * without it (the pure workspace dashboard) `activeSandboxId` is null and only
162
162
  * the enrolled machines are listed.
163
163
  */
@@ -184,9 +184,9 @@ export async function listMachines(
184
184
 
185
185
  const machines: MachineView[] = [];
186
186
 
187
- // The session's own Modal group box (synthetic): the default/home sandbox a
188
- // null active pointer routes to. Only present in an in-session view.
189
- if (session) {
187
+ // The session's own group box (synthetic): the default/home sandbox a null
188
+ // active pointer routes to. A backend:none session has no home box.
189
+ if (session && session.sandboxBackend !== "none") {
190
190
  const groupActive = activeSandboxId === null;
191
191
  const groupLease = await readLease(db, workspaceId, session.sandboxGroupId);
192
192
  machines.push(
@@ -32,8 +32,10 @@
32
32
 
33
33
  import {
34
34
  clearEnrollmentWentOffline,
35
+ disconnectAttachedBrowserDevices,
35
36
  getEnrollment,
36
37
  ingestMachineMetricsSample,
38
+ reconcileAttachedBrowserInventory,
37
39
  sessionsWithActiveOpOnEnrollment,
38
40
  setEnrollmentDisplayState,
39
41
  setEnrollmentOpStreamState,
@@ -43,13 +45,20 @@ import {
43
45
  type Database,
44
46
  type MachineMetricsSample,
45
47
  } from "@opengeni/db";
48
+ import {
49
+ AttachedBrowserInventorySnapshot as AttachedBrowserInventoryContract,
50
+ type AttachedBrowserInventorySnapshot as AttachedBrowserInventoryContractValue,
51
+ } from "@opengeni/contracts";
46
52
  import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
47
53
  import type { Observability } from "@opengeni/observability";
48
54
  import {
49
55
  AgentEvent,
56
+ Arch,
50
57
  GoingOfflineReason,
51
58
  Hello,
59
+ Os,
52
60
  goingOfflineReasonToJSON,
61
+ type AttachedBrowserInventorySnapshot as WireAttachedBrowserInventorySnapshot,
53
62
  type MetricsSample,
54
63
  } from "@opengeni/agent-proto";
55
64
 
@@ -124,6 +133,90 @@ export function wireSampleToDbSample(wire: MetricsSample): MachineMetricsSample
124
133
  };
125
134
  }
126
135
 
136
+ /** Validate and project one wire inventory before it reaches durable browser
137
+ * discovery. Unknown enum values and unsafe uint64 counters reject the whole
138
+ * authoritative snapshot instead of partially disconnecting good endpoints. */
139
+ export function wireAttachedBrowserInventoryToContract(
140
+ wire: WireAttachedBrowserInventorySnapshot,
141
+ ): AttachedBrowserInventoryContractValue {
142
+ return AttachedBrowserInventoryContract.parse({
143
+ bridgeGeneration: wire.bridgeGeneration,
144
+ revision: safeWireInteger(wire.revision, "browser inventory revision"),
145
+ devices: wire.devices.map((device) => ({
146
+ id: device.id,
147
+ name: device.name,
148
+ profileLabel: device.profileLabel.trim() || null,
149
+ browserName: device.browserName,
150
+ browserVersion: device.browserVersion,
151
+ extensionVersion: device.extensionVersion,
152
+ platform: wireBrowserPlatform(device.platform),
153
+ architecture: wireBrowserArchitecture(device.arch),
154
+ connectionGeneration: device.connectionGeneration,
155
+ inventoryRevision: safeWireInteger(
156
+ device.inventoryRevision,
157
+ "attached browser inventory revision",
158
+ ),
159
+ tabCount: safeWireInteger(device.tabCount, "attached browser tab count"),
160
+ capabilities: device.capabilities,
161
+ })),
162
+ });
163
+ }
164
+
165
+ function safeWireInteger(value: string | number, label: string): number {
166
+ let parsed: bigint;
167
+ try {
168
+ parsed = BigInt(value);
169
+ } catch {
170
+ throw new Error(`${label} is not an integer`);
171
+ }
172
+ if (parsed < 0n || parsed > BigInt(Number.MAX_SAFE_INTEGER)) {
173
+ throw new Error(`${label} is outside the safe integer range`);
174
+ }
175
+ return Number(parsed);
176
+ }
177
+
178
+ function wireBrowserPlatform(platform: Os): "linux" | "macos" | "windows" {
179
+ switch (platform) {
180
+ case Os.OS_LINUX:
181
+ return "linux";
182
+ case Os.OS_MACOS:
183
+ return "macos";
184
+ case Os.OS_WINDOWS:
185
+ return "windows";
186
+ default:
187
+ throw new Error("attached browser platform is unspecified or unsupported");
188
+ }
189
+ }
190
+
191
+ function wireBrowserArchitecture(architecture: Arch): "x64" | "arm64" {
192
+ switch (architecture) {
193
+ case Arch.ARCH_X86_64:
194
+ return "x64";
195
+ case Arch.ARCH_AARCH64:
196
+ return "arm64";
197
+ default:
198
+ throw new Error("attached browser architecture is unspecified or unsupported");
199
+ }
200
+ }
201
+
202
+ async function ingestAttachedBrowserInventory(
203
+ db: Database,
204
+ input: {
205
+ workspaceId: string;
206
+ agentId: string;
207
+ inventory: WireAttachedBrowserInventorySnapshot;
208
+ },
209
+ ): Promise<void> {
210
+ const enrollment = await getEnrollment(db, input.workspaceId, input.agentId);
211
+ if (!enrollment) return;
212
+ await reconcileAttachedBrowserInventory(db, {
213
+ accountId: enrollment.accountId,
214
+ workspaceId: input.workspaceId,
215
+ enrollmentId: input.agentId,
216
+ snapshot: wireAttachedBrowserInventoryToContract(input.inventory),
217
+ });
218
+ }
219
+
127
220
  /**
128
221
  * Ingest ONE decoded heartbeat for an enrolled machine. Resolves the enrollment's
129
222
  * accountId (needed for the RLS-scoped writes) from the enrollment row; an
@@ -253,6 +346,18 @@ export async function handleAgentEventPayload(
253
346
  enrollmentId: ids.agentId,
254
347
  reason,
255
348
  });
349
+ try {
350
+ await disconnectAttachedBrowserDevices(db, {
351
+ accountId: enrollment.accountId,
352
+ workspaceId: ids.workspaceId,
353
+ enrollmentId: ids.agentId,
354
+ });
355
+ } catch (error) {
356
+ observability?.warn?.("Failed to disconnect attached browsers with their machine", {
357
+ subject,
358
+ error: error instanceof Error ? error.message : String(error),
359
+ });
360
+ }
256
361
  // Fan out the link-plane events to the sessions with an active op on this
257
362
  // machine: machine.link.lost (its control link is going away) for every
258
363
  // clean going-offline, PLUS machine.runner.restarted when the reason is a
@@ -294,10 +399,23 @@ export async function handleAgentEventPayload(
294
399
  if (event.event?.$case !== "heartbeat") {
295
400
  return; // an unknown event kind → not a metrics point.
296
401
  }
297
- const metrics = event.event.heartbeat.metrics;
298
- if (!metrics) {
299
- return; // a heartbeat without a sample → liveness already touched elsewhere.
402
+ const heartbeat = event.event.heartbeat;
403
+ if (heartbeat.attachedBrowserInventory) {
404
+ try {
405
+ await ingestAttachedBrowserInventory(db, {
406
+ workspaceId: ids.workspaceId,
407
+ agentId: ids.agentId,
408
+ inventory: heartbeat.attachedBrowserInventory,
409
+ });
410
+ } catch (error) {
411
+ observability?.warn?.("Failed to ingest an attached-browser inventory", {
412
+ subject,
413
+ error: error instanceof Error ? error.message : String(error),
414
+ });
415
+ }
300
416
  }
417
+ const metrics = heartbeat.metrics;
418
+ if (!metrics) return;
301
419
  try {
302
420
  await ingestHeartbeat(db, {
303
421
  workspaceId: ids.workspaceId,
@@ -21,8 +21,11 @@ import {
21
21
  requirePersistableReplacementSandboxEnvelope,
22
22
  resolveModalCheckpointProviderBindingForSession,
23
23
  serializeReplacementSandboxEnvelope,
24
+ SandboxProviderContinuityUnavailableError,
24
25
  tagModalSandbox,
26
+ terminateUnpublishedSandboxSession,
25
27
  verifySandboxExecReadiness,
28
+ withoutSandboxProviderIdentity,
26
29
  WorkspaceArchiveIntegrityError,
27
30
  type EstablishedSandboxSession,
28
31
  type WorkspaceArchiveDescriptor,
@@ -52,39 +55,10 @@ function legacyNativeArchiveFromEnvelope(envelope: Record<string, unknown> | nul
52
55
  );
53
56
  }
54
57
 
55
- function withoutProviderIdentity(
56
- envelope: Record<string, unknown> | null,
57
- ): Record<string, unknown> | null {
58
- if (!envelope) return null;
59
- const sessionState =
60
- envelope.sessionState && typeof envelope.sessionState === "object"
61
- ? (envelope.sessionState as Record<string, unknown>)
62
- : null;
63
- if (!sessionState) return envelope;
64
- const { providerState: _providerState, ...providerIndependentState } = sessionState;
65
- return { ...envelope, sessionState: providerIndependentState };
66
- }
67
-
68
58
  async function terminateCreated(established: EstablishedSandboxSession | null): Promise<boolean> {
69
59
  if (!established) return true;
70
- const client = established.client as {
71
- delete?: (state: unknown) => Promise<unknown>;
72
- };
73
60
  try {
74
- if (typeof client.delete === "function" && established.sessionState !== undefined) {
75
- await client.delete(established.sessionState);
76
- return true;
77
- }
78
- const session = established.session as {
79
- terminate?: () => Promise<unknown>;
80
- kill?: () => Promise<unknown>;
81
- close?: () => Promise<unknown>;
82
- closed?: boolean;
83
- };
84
- if (session.terminate) await session.terminate();
85
- else if (session.kill) await session.kill();
86
- else if (session.close && !session.closed) await session.close();
87
- else return false;
61
+ await terminateUnpublishedSandboxSession(established);
88
62
  return true;
89
63
  } catch (error) {
90
64
  return isProviderSandboxNotFoundError(established.backendId, error);
@@ -112,7 +86,7 @@ export async function establishApiSandboxSpawner(input: {
112
86
  const fallbackArchiveEnvelope =
113
87
  input.acquiredLease.recovery.archive.status === "none" &&
114
88
  hasWorkspaceArchive(input.fallbackEnvelope)
115
- ? withoutProviderIdentity(input.fallbackEnvelope)
89
+ ? withoutSandboxProviderIdentity(input.fallbackEnvelope)
116
90
  : null;
117
91
  let spawnEnvelope =
118
92
  fallbackArchiveEnvelope ?? input.acquiredLease.resumeState ?? input.fallbackEnvelope;
@@ -131,10 +105,12 @@ export async function establishApiSandboxSpawner(input: {
131
105
  ReturnType<typeof resolveModalCheckpointProviderBindingForSession>
132
106
  > | null;
133
107
  } | null = null;
108
+ const continuityRecovery = input.acquiredLease.recovery.continuity;
134
109
  try {
135
110
  if (
136
- input.acquiredLease.recovery.archive.status === "available" ||
137
- hasWorkspaceArchive(archiveSource)
111
+ (input.acquiredLease.recovery.archive.status === "available" &&
112
+ input.acquiredLease.archiveComplete) ||
113
+ (input.acquiredLease.recovery.archive.status === "none" && hasWorkspaceArchive(archiveSource))
138
114
  ) {
139
115
  const id = crypto.randomUUID();
140
116
  const legacyNativeArchive = legacyNativeArchiveFromEnvelope(archiveSource);
@@ -177,7 +153,7 @@ export async function establishApiSandboxSpawner(input: {
177
153
  legacyCheckpoint: begun.checkpointArtifact === null ? legacyNativeArchive : null,
178
154
  legacyProviderBinding: null,
179
155
  };
180
- } else if (input.acquiredLease.recovery.archive.status !== "none") {
156
+ } else if (input.acquiredLease.recovery.archive.status !== "none" && !continuityRecovery) {
181
157
  throw new SandboxLeaseRecoveryBlockedError(
182
158
  input.sandboxGroupId,
183
159
  input.expectedEpoch,
@@ -235,6 +211,7 @@ export async function establishApiSandboxSpawner(input: {
235
211
  sandboxGroupId: input.sandboxGroupId,
236
212
  expectedEpoch: input.expectedEpoch,
237
213
  rematerializationId: rematerialization?.id ?? null,
214
+ ...(created.providerContinuity ? { continuityRecovery: created.providerContinuity } : {}),
238
215
  instanceId: created.instanceId,
239
216
  resumeBackendId: created.backendId,
240
217
  resumeState,
@@ -310,6 +287,7 @@ export async function establishApiSandboxSpawner(input: {
310
287
  await verifySandboxExecReadiness(established);
311
288
  if (
312
289
  rematerialization &&
290
+ !established.providerContinuity &&
313
291
  established.restoredArchive?.revision !== rematerialization.selectedRevision
314
292
  ) {
315
293
  throw new WorkspaceArchiveIntegrityError(
@@ -330,19 +308,21 @@ export async function establishApiSandboxSpawner(input: {
330
308
  dataPlaneUrl: input.dataPlaneUrl,
331
309
  resumeBackendId: established.backendId,
332
310
  resumeState,
333
- ...(rematerialization
334
- ? {
335
- rematerialization: {
336
- id: rematerialization.id,
337
- verifiedRevision: rematerialization.selectedRevision,
338
- },
339
- }
340
- : {}),
311
+ ...(established.providerContinuity
312
+ ? { continuityRecovery: established.providerContinuity }
313
+ : rematerialization
314
+ ? {
315
+ rematerialization: {
316
+ id: rematerialization.id,
317
+ verifiedRevision: rematerialization.selectedRevision,
318
+ },
319
+ }
320
+ : {}),
341
321
  leaseTtlMs: input.settings.sandboxLeaseTtlMs,
342
322
  });
343
323
  if (!committed.committed || !committed.lease) {
344
324
  const terminated = await terminateCreated(established);
345
- if (terminated && rematerialization) {
325
+ if (terminated && rematerialization && !established.providerContinuity) {
346
326
  await failSandboxRematerialization(input.db, {
347
327
  accountId: input.accountId,
348
328
  workspaceId: input.workspaceId,
@@ -367,18 +347,25 @@ export async function establishApiSandboxSpawner(input: {
367
347
  if (error instanceof SandboxLeaseSupersededError) throw error;
368
348
  const terminated = await terminateCreated(established);
369
349
  if (terminated) {
370
- if (rematerialization) {
350
+ const continuityUnavailable = error instanceof SandboxProviderContinuityUnavailableError;
351
+ if (rematerialization && !established?.providerContinuity) {
371
352
  await failSandboxRematerialization(input.db, {
372
353
  accountId: input.accountId,
373
354
  workspaceId: input.workspaceId,
374
355
  sandboxGroupId: input.sandboxGroupId,
375
356
  expectedEpoch: input.expectedEpoch,
376
357
  rematerializationId: rematerialization.id,
377
- failureCode:
378
- error instanceof WorkspaceArchiveIntegrityError
358
+ failureCode: continuityUnavailable
359
+ ? "provider_continuity_unavailable"
360
+ : error instanceof WorkspaceArchiveIntegrityError
379
361
  ? error.code
380
362
  : "sandbox_rematerialization_failed",
381
- retryable: error instanceof WorkspaceArchiveIntegrityError ? error.retryable : true,
363
+ retryable: continuityUnavailable
364
+ ? error.retryable
365
+ : error instanceof WorkspaceArchiveIntegrityError
366
+ ? error.retryable
367
+ : true,
368
+ ...(continuityUnavailable ? { discardContinuity: true } : {}),
382
369
  });
383
370
  } else {
384
371
  await failWarmingToCold(input.db, {
@@ -386,6 +373,7 @@ export async function establishApiSandboxSpawner(input: {
386
373
  workspaceId: input.workspaceId,
387
374
  sandboxGroupId: input.sandboxGroupId,
388
375
  expectedEpoch: input.expectedEpoch,
376
+ ...(continuityUnavailable ? { discardContinuity: true } : {}),
389
377
  });
390
378
  }
391
379
  }
@@ -9,7 +9,8 @@
9
9
  // attach -> acquireLease(kind:'viewer') under FOR UPDATE + cold->warming CAS.
10
10
  // spawner role -> establish the box in-process + commitWarmingToWarm.
11
11
  // attached/rearmed -> the holder alone keeps the box warm.
12
- // fenced -> release + surface a 409 (a newer epoch re-established it).
12
+ // lifecycle claim -> wait for settlement; true epoch supersession
13
+ // -> release + surface a 409.
13
14
  // heartbeat -> heartbeatLeaseHolder (epoch-fenced) refreshes the holder TTL.
14
15
  // detach -> releaseLeaseHolder (idempotent); the reaper (P1.3) stop()s the
15
16
  // box at refcount 0 past the drain grace.
@@ -24,7 +25,7 @@ import {
24
25
  hasGitCredentialRepositorySelection,
25
26
  hasGitHubRepositorySelection,
26
27
  resolveStreamTokenSecret,
27
- sandboxArchiveCaptureTimeoutMs,
28
+ sandboxLifecycleTransitionWaitMs,
28
29
  stableSandboxEnvironmentForRun,
29
30
  } from "@opengeni/config";
30
31
  import type { Settings } from "@opengeni/config";
@@ -62,6 +63,8 @@ import {
62
63
  exposeStreamPort,
63
64
  desktopCapableBackend,
64
65
  NatsControlRpc,
66
+ SandboxResumeIdentityMismatchError,
67
+ SandboxResumeIdentityUnavailableError,
65
68
  SelfhostedSandboxClient,
66
69
  TERMINAL_STREAM_PORT,
67
70
  DisplayStackUnsupportedError,
@@ -179,7 +182,14 @@ export async function sessionAttachEnvironment(
179
182
  */
180
183
  export async function attachViewer(
181
184
  services: ViewerServices,
182
- input: { accountId: string; workspaceId: string; session: Session; viewerId?: string },
185
+ input: {
186
+ accountId: string;
187
+ workspaceId: string;
188
+ session: Session;
189
+ viewerId?: string;
190
+ /** Cancel lifecycle waiting when the originating HTTP request disconnects. */
191
+ waitSignal?: AbortSignal;
192
+ },
183
193
  ): Promise<ViewerAttachResult> {
184
194
  const { db, settings } = services;
185
195
  const { accountId, workspaceId, session } = input;
@@ -211,7 +221,8 @@ export async function attachViewer(
211
221
  os: session.sandboxOs,
212
222
  leaseTtlMs,
213
223
  warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
214
- captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
224
+ captureWaitMs: sandboxLifecycleTransitionWaitMs(settings),
225
+ ...(input.waitSignal ? { waitSignal: input.waitSignal } : {}),
215
226
  });
216
227
  } catch (error) {
217
228
  if (error instanceof SandboxViewerAdmissionBlockedError) {
@@ -237,7 +248,10 @@ export async function attachViewer(
237
248
  if (acquired.role === "fenced") {
238
249
  await release();
239
250
  throw new HTTPException(409, {
240
- message: `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); re-read capabilities and re-attach`,
251
+ message:
252
+ acquired.reason === "superseded"
253
+ ? `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); re-read capabilities and re-attach`
254
+ : `sandbox lifecycle transition in progress (${acquired.reason}, epoch ${acquired.lease.leaseEpoch}, backend ${acquired.lease.backend}, instance ${acquired.lease.instanceId ?? "none"}); retry`,
241
255
  });
242
256
  }
243
257
 
@@ -362,6 +376,12 @@ export async function attachViewer(
362
376
  message: `sandbox instance was lost; retry to restore it`,
363
377
  });
364
378
  }
379
+ if (
380
+ error instanceof SandboxResumeIdentityMismatchError ||
381
+ error instanceof SandboxResumeIdentityUnavailableError
382
+ ) {
383
+ throw new HTTPException(409, { message: error.message });
384
+ }
365
385
  throw error;
366
386
  } finally {
367
387
  await dropEstablishedHandle(observed);
@@ -625,7 +645,7 @@ export type MintDesktopStreamInput = {
625
645
  session: Session;
626
646
  /** The viewer holder id the scoped token is minted for. */
627
647
  viewerId: string;
628
- /** The live lease (must be warm/draining — the box is up). A selfhosted-active
648
+ /** The live lease (must be holder-confirmed warm). A selfhosted-active
629
649
  * session may have no Modal group lease; omit and the selfhosted branch handles it. */
630
650
  lease?: LeaseSnapshot;
631
651
  /** The epoch the CALLER last observed the URL minted under. When the live
@@ -717,7 +737,7 @@ export async function mintDesktopStream(
717
737
 
718
738
  // GATE 2: the box must be live (the handshake never spins one up — a cold box
719
739
  // returns lease_cold; the viewer-attach path warms it first, then mints).
720
- if (!lease || (lease.liveness !== "warm" && lease.liveness !== "draining")) {
740
+ if (!lease || lease.liveness !== "warm") {
721
741
  return null;
722
742
  }
723
743
 
@@ -871,15 +891,18 @@ export type TerminalStreamMint = {
871
891
  token: string;
872
892
  expiresAt: string;
873
893
  leaseEpoch: number;
894
+ transport: "pty-ws" | "relay-pty";
874
895
  };
875
896
 
897
+ type SelfhostedStreamMint = Omit<TerminalStreamMint, "transport">;
898
+
876
899
  export type MintTerminalStreamInput = {
877
900
  accountId: string;
878
901
  workspaceId: string;
879
902
  session: Session;
880
903
  /** The viewer holder / principal id the scoped token is minted for. */
881
904
  viewerId: string;
882
- /** The live lease (must be warm/draining — the box is up). A selfhosted-active
905
+ /** The live lease (must be holder-confirmed warm). A selfhosted-active
883
906
  * session may have no Modal group lease; omit and the selfhosted branch handles it. */
884
907
  lease?: LeaseSnapshot;
885
908
  /** Test seam: override how the box is re-established by id (see
@@ -930,7 +953,7 @@ export async function mintTerminalStream(
930
953
  if (session.activeSandboxId) {
931
954
  const active = await getSandbox(db, workspaceId, session.activeSandboxId);
932
955
  if (active?.kind === "selfhosted") {
933
- return await tryMintActiveSelfhostedStream(
956
+ const stream = await tryMintActiveSelfhostedStream(
934
957
  services,
935
958
  {
936
959
  session,
@@ -941,13 +964,14 @@ export async function mintTerminalStream(
941
964
  },
942
965
  input.resolveSelfhostedSession,
943
966
  );
967
+ return stream ? { ...stream, transport: "relay-pty" } : null;
944
968
  }
945
969
  // A Modal swap target (or unknown) falls through to the existing group-box path
946
970
  // (unchanged — Modal swap-target streaming is out of scope for this fix).
947
971
  }
948
972
 
949
973
  // GATE 2: the box must be live (the handshake never spins one up).
950
- if (!lease || (lease.liveness !== "warm" && lease.liveness !== "draining")) {
974
+ if (!lease || lease.liveness !== "warm") {
951
975
  return null;
952
976
  }
953
977
 
@@ -1032,6 +1056,7 @@ export async function mintTerminalStream(
1032
1056
  token: exposed.token,
1033
1057
  expiresAt: exposed.expiresAt,
1034
1058
  leaseEpoch: lease.leaseEpoch,
1059
+ transport: "pty-ws",
1035
1060
  };
1036
1061
  } catch {
1037
1062
  // Any other failure degrades the terminal pty-ws cell to the sse-events
@@ -1087,7 +1112,7 @@ async function tryMintActiveSelfhostedStream(
1087
1112
  resolveSelfhostedSession?: (
1088
1113
  sandbox: SandboxRecord,
1089
1114
  ) => Promise<{ resolveExposedPort?: (port: number) => Promise<unknown> }>,
1090
- ): Promise<TerminalStreamMint | null> {
1115
+ ): Promise<SelfhostedStreamMint | null> {
1091
1116
  const { settings, bus } = services;
1092
1117
  const { session, workspaceId, port, sandbox } = input;
1093
1118
  if (!sandbox.enrollmentId) {
@@ -1108,6 +1133,8 @@ async function tryMintActiveSelfhostedStream(
1108
1133
  controlRpcFactory: () => controlRpc(bus),
1109
1134
  agentId: sandbox.enrollmentId,
1110
1135
  epoch: session.activeEpoch,
1136
+ // Stream authority rotates; terminal process identity does not.
1137
+ terminalScopeId: session.id,
1111
1138
  });
1112
1139
  shSession = await client.resume({ agentId: sandbox.enrollmentId });
1113
1140
  }
@@ -1165,7 +1192,7 @@ export type MintSelfhostedStreamInput = {
1165
1192
  export async function mintSelfhostedStream(
1166
1193
  services: ViewerServices,
1167
1194
  input: MintSelfhostedStreamInput,
1168
- ): Promise<TerminalStreamMint | null> {
1195
+ ): Promise<SelfhostedStreamMint | null> {
1169
1196
  const { settings } = services;
1170
1197
  const secret = resolveStreamTokenSecret(settings);
1171
1198
  if (!secret) {