@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
@@ -75,8 +75,9 @@ export const DEVICE_POLL_INTERVAL_SECONDS = 5;
75
75
  // box) caused a self-hosted agent to drop PERMANENTLY one hour after connecting: the
76
76
  // bearer expired and the auth-callout rejected every reconnect ("re-enroll may be
77
77
  // required"). A long-lived bearer is safe because the auth-callout RE-CHECKS the
78
- // enrollment status on every (re)connect (auth-callout.ts) — a revoked machine is
79
- // denied regardless of bearer life exactly as the long-lived relay token relies on.
78
+ // enrollment status AND credential generation on every (re)connect
79
+ // (auth-callout.ts) a revoked machine or an old pre-re-enrollment bearer is denied
80
+ // regardless of bearer life. The short NATS user-JWT cap bounds already-live access.
80
81
  export const ENROLLMENT_BEARER_TTL_SECONDS = 30 * 24 * 3600;
81
82
  // The relay PRODUCER token (the `ogr_` token; M8b) is ENROLLMENT-scoped,
82
83
  // NOT per-stream: the agent presents it on every channel registration for the life
@@ -370,6 +371,7 @@ export async function exchangeEnrollToken(
370
371
  secret,
371
372
  workspaceId: claims.workspaceId,
372
373
  agentId: enrollment.id,
374
+ credentialGeneration: enrollment.credentialGeneration,
373
375
  consentedScreenControl: enrollment.allowScreenControl,
374
376
  });
375
377
  return { ok: true, credentials };
@@ -430,6 +432,7 @@ export async function pollDeviceEnrollment(
430
432
  secret,
431
433
  workspaceId: request.workspaceId,
432
434
  agentId: enrollment.id,
435
+ credentialGeneration: enrollment.credentialGeneration,
433
436
  consentedScreenControl: enrollment.allowScreenControl,
434
437
  });
435
438
 
@@ -455,7 +458,13 @@ export async function pollDeviceEnrollment(
455
458
  * bearer so an agent using it as the connect-token credential works uniformly. */
456
459
  async function buildEnrollmentCredentials(
457
460
  services: EnrollmentServices,
458
- input: { secret: string; workspaceId: string; agentId: string; consentedScreenControl: boolean },
461
+ input: {
462
+ secret: string;
463
+ workspaceId: string;
464
+ agentId: string;
465
+ credentialGeneration: number;
466
+ consentedScreenControl: boolean;
467
+ },
459
468
  ): Promise<EnrollmentCredentialsResponse> {
460
469
  const { settings } = services;
461
470
  // The control-plane subject prefix the agent subscribes to: agent.<ws>.<id>.
@@ -466,6 +475,7 @@ async function buildEnrollmentCredentials(
466
475
  workspaceId: input.workspaceId,
467
476
  agentId: input.agentId,
468
477
  enrollmentId: input.agentId,
478
+ credentialGeneration: input.credentialGeneration,
469
479
  subjectPrefix,
470
480
  exp,
471
481
  });
@@ -219,7 +219,7 @@ export async function listMachines(
219
219
  // onto the machines (no N+1). Each machine is probed for liveness.
220
220
  const [sandboxes, enrollments, metricsByEnrollment] = await Promise.all([
221
221
  listSandboxes(db, workspaceId),
222
- listEnrollments(db, workspaceId),
222
+ listEnrollments(db, workspaceId, { status: "active" }),
223
223
  readMachineMetricsLatestForWorkspace(db, workspaceId),
224
224
  ]);
225
225
  const enrollmentById = new Map(enrollments.map((e) => [e.id, e]));
@@ -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,
@@ -74,6 +77,7 @@ import {
74
77
  } from "@opengeni/runtime/sandbox";
75
78
  import { relayConfigFromSettings } from "@opengeni/core";
76
79
  import { establishApiSandboxSpawner } from "./rematerialize";
80
+ import { establishCachedChannelAHandle } from "./channel-a";
77
81
 
78
82
  /** The minimal services a viewer op needs: the DB + settings (lease cadence +
79
83
  * the sandbox client construction the leaf reads from settings). The bus is
@@ -178,7 +182,14 @@ export async function sessionAttachEnvironment(
178
182
  */
179
183
  export async function attachViewer(
180
184
  services: ViewerServices,
181
- 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
+ },
182
193
  ): Promise<ViewerAttachResult> {
183
194
  const { db, settings } = services;
184
195
  const { accountId, workspaceId, session } = input;
@@ -210,7 +221,8 @@ export async function attachViewer(
210
221
  os: session.sandboxOs,
211
222
  leaseTtlMs,
212
223
  warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
213
- captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
224
+ captureWaitMs: sandboxLifecycleTransitionWaitMs(settings),
225
+ ...(input.waitSignal ? { waitSignal: input.waitSignal } : {}),
214
226
  });
215
227
  } catch (error) {
216
228
  if (error instanceof SandboxViewerAdmissionBlockedError) {
@@ -236,7 +248,10 @@ export async function attachViewer(
236
248
  if (acquired.role === "fenced") {
237
249
  await release();
238
250
  throw new HTTPException(409, {
239
- 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`,
240
255
  });
241
256
  }
242
257
 
@@ -361,6 +376,12 @@ export async function attachViewer(
361
376
  message: `sandbox instance was lost; retry to restore it`,
362
377
  });
363
378
  }
379
+ if (
380
+ error instanceof SandboxResumeIdentityMismatchError ||
381
+ error instanceof SandboxResumeIdentityUnavailableError
382
+ ) {
383
+ throw new HTTPException(409, { message: error.message });
384
+ }
364
385
  throw error;
365
386
  } finally {
366
387
  await dropEstablishedHandle(observed);
@@ -624,7 +645,7 @@ export type MintDesktopStreamInput = {
624
645
  session: Session;
625
646
  /** The viewer holder id the scoped token is minted for. */
626
647
  viewerId: string;
627
- /** 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
628
649
  * session may have no Modal group lease; omit and the selfhosted branch handles it. */
629
650
  lease?: LeaseSnapshot;
630
651
  /** The epoch the CALLER last observed the URL minted under. When the live
@@ -716,7 +737,7 @@ export async function mintDesktopStream(
716
737
 
717
738
  // GATE 2: the box must be live (the handshake never spins one up — a cold box
718
739
  // returns lease_cold; the viewer-attach path warms it first, then mints).
719
- if (!lease || (lease.liveness !== "warm" && lease.liveness !== "draining")) {
740
+ if (!lease || lease.liveness !== "warm") {
720
741
  return null;
721
742
  }
722
743
 
@@ -730,18 +751,22 @@ export async function mintDesktopStream(
730
751
  // SAME stable run-env the turn declares, so a later turn finds no env delta.
731
752
  const environment = await sessionAttachEnvironment(services, workspaceId, session);
732
753
  try {
754
+ const establish = () =>
755
+ (services.establishSandboxSession ?? establishSandboxSessionFromEnvelope)(
756
+ settings,
757
+ envelope,
758
+ {
759
+ sessionId: session.id,
760
+ recovery: "resume-only",
761
+ backendOverride: session.sandboxBackend,
762
+ environment,
763
+ },
764
+ );
733
765
  established = input.establish
734
766
  ? await input.establish(envelope)
735
- : await (services.establishSandboxSession ?? establishSandboxSessionFromEnvelope)(
736
- settings,
737
- envelope,
738
- {
739
- sessionId: session.id,
740
- recovery: "resume-only",
741
- backendOverride: session.sandboxBackend,
742
- environment,
743
- },
744
- );
767
+ : services.establishSandboxSession
768
+ ? await establish()
769
+ : await establishCachedChannelAHandle(workspaceId, session.id, lease, establish);
745
770
  } catch (error) {
746
771
  await retireMissingWarmLease(services, { accountId, workspaceId, session, lease }, error);
747
772
  return null;
@@ -874,7 +899,7 @@ export type MintTerminalStreamInput = {
874
899
  session: Session;
875
900
  /** The viewer holder / principal id the scoped token is minted for. */
876
901
  viewerId: string;
877
- /** The live lease (must be warm/draining — the box is up). A selfhosted-active
902
+ /** The live lease (must be holder-confirmed warm). A selfhosted-active
878
903
  * session may have no Modal group lease; omit and the selfhosted branch handles it. */
879
904
  lease?: LeaseSnapshot;
880
905
  /** Test seam: override how the box is re-established by id (see
@@ -942,7 +967,7 @@ export async function mintTerminalStream(
942
967
  }
943
968
 
944
969
  // GATE 2: the box must be live (the handshake never spins one up).
945
- if (!lease || (lease.liveness !== "warm" && lease.liveness !== "draining")) {
970
+ if (!lease || lease.liveness !== "warm") {
946
971
  return null;
947
972
  }
948
973
 
@@ -955,18 +980,22 @@ export async function mintTerminalStream(
955
980
  // declares, so a later turn finds no manifest-env delta.
956
981
  const environment = await sessionAttachEnvironment(services, workspaceId, session);
957
982
  try {
983
+ const establish = () =>
984
+ (services.establishSandboxSession ?? establishSandboxSessionFromEnvelope)(
985
+ settings,
986
+ envelope,
987
+ {
988
+ sessionId: session.id,
989
+ recovery: "resume-only",
990
+ backendOverride: session.sandboxBackend,
991
+ environment,
992
+ },
993
+ );
958
994
  established = input.establish
959
995
  ? await input.establish(envelope)
960
- : await (services.establishSandboxSession ?? establishSandboxSessionFromEnvelope)(
961
- settings,
962
- envelope,
963
- {
964
- sessionId: session.id,
965
- recovery: "resume-only",
966
- backendOverride: session.sandboxBackend,
967
- environment,
968
- },
969
- );
996
+ : services.establishSandboxSession
997
+ ? await establish()
998
+ : await establishCachedChannelAHandle(workspaceId, session.id, lease, establish);
970
999
  } catch (error) {
971
1000
  await retireMissingWarmLease(services, { accountId, workspaceId, session, lease }, error);
972
1001
  return null;