@opengeni/api-router 0.23.1 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/browser-controller-authority.d.ts +43 -0
  4. package/dist/browser-state-authority.d.ts +27 -0
  5. package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
  6. package/dist/chunk-JIKNR5YL.js.map +1 -0
  7. package/dist/codemode.d.ts +23 -0
  8. package/dist/editable-artifact-live-hints.d.ts +11 -0
  9. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  10. package/dist/editable-artifact-office-import.d.ts +22 -0
  11. package/dist/editable-artifact-production.d.ts +29 -0
  12. package/dist/editable-artifact-websocket.d.ts +49 -0
  13. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  14. package/dist/github-browser-flow.d.ts +6 -0
  15. package/dist/http/cors.d.ts +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1796 -22
  18. package/dist/index.js.map +1 -1
  19. package/dist/integrations/api-integrations.d.ts +24 -0
  20. package/dist/integrations/atlassian.d.ts +176 -0
  21. package/dist/integrations/github-skill-source.d.ts +5 -0
  22. package/dist/integrations/google-drive.d.ts +85 -0
  23. package/dist/integrations/oauth-client.d.ts +22 -1
  24. package/dist/integrations/provider-oauth.d.ts +19 -0
  25. package/dist/integrations/slack-bot.d.ts +4 -0
  26. package/dist/integrations/slack-interactions.d.ts +8 -2
  27. package/dist/integrations/social-api.d.ts +2 -1
  28. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  29. package/dist/mcp/editable-artifacts.d.ts +13 -0
  30. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  31. package/dist/mcp/server.d.ts +14 -3
  32. package/dist/memory-slack-delivery.d.ts +9 -0
  33. package/dist/routes/api-integrations.d.ts +8 -0
  34. package/dist/routes/browser-identities.d.ts +5 -0
  35. package/dist/routes/browser-sessions.d.ts +6 -0
  36. package/dist/routes/company-profile.d.ts +3 -0
  37. package/dist/routes/computer-sessions.d.ts +6 -0
  38. package/dist/routes/editable-artifacts.d.ts +44 -0
  39. package/dist/routes/integration-features.d.ts +3 -0
  40. package/dist/routes/memory-slack-publications.d.ts +6 -0
  41. package/dist/routes/plugins.d.ts +8 -0
  42. package/dist/routes/sessions.d.ts +17 -2
  43. package/dist/routes/skills.d.ts +6 -0
  44. package/dist/routes/video-generation.d.ts +3 -0
  45. package/dist/sandbox/channel-a.d.ts +55 -2
  46. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  47. package/dist/sandbox/viewer.d.ts +4 -2
  48. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  49. package/package.json +19 -14
  50. package/src/app.ts +274 -37
  51. package/src/browser-controller-authority.ts +137 -0
  52. package/src/browser-state-authority.ts +236 -0
  53. package/src/codemode.ts +186 -0
  54. package/src/editable-artifact-live-hints.ts +64 -0
  55. package/src/editable-artifact-native-kernel.ts +659 -0
  56. package/src/editable-artifact-office-import.ts +230 -0
  57. package/src/editable-artifact-production.ts +419 -0
  58. package/src/editable-artifact-websocket.ts +311 -0
  59. package/src/editable-artifact-workspace-files.ts +186 -0
  60. package/src/github-browser-flow.ts +35 -6
  61. package/src/http/auth.ts +2 -0
  62. package/src/http/cors.ts +3 -0
  63. package/src/index.ts +119 -20
  64. package/src/integrations/api-integrations.ts +350 -0
  65. package/src/integrations/atlassian.ts +1621 -0
  66. package/src/integrations/github-skill-source.ts +142 -0
  67. package/src/integrations/google-drive.ts +1000 -64
  68. package/src/integrations/oauth-client.ts +123 -33
  69. package/src/integrations/provider-oauth.ts +777 -0
  70. package/src/integrations/slack-bot.ts +31 -2
  71. package/src/integrations/slack-interactions.ts +487 -27
  72. package/src/integrations/social-api.ts +11 -0
  73. package/src/mcp/documents.ts +32 -1
  74. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  75. package/src/mcp/editable-artifacts.ts +448 -0
  76. package/src/mcp/scheduled-task-view.ts +34 -0
  77. package/src/mcp/server.ts +923 -145
  78. package/src/memory-slack-delivery.ts +209 -0
  79. package/src/routes/api-integrations.ts +407 -0
  80. package/src/routes/browser-identities.ts +136 -0
  81. package/src/routes/browser-sessions.ts +2543 -0
  82. package/src/routes/company-profile.ts +255 -0
  83. package/src/routes/computer-sessions.ts +1247 -0
  84. package/src/routes/connections.ts +284 -99
  85. package/src/routes/documents.ts +22 -3
  86. package/src/routes/editable-artifacts.ts +1159 -0
  87. package/src/routes/files.ts +106 -4
  88. package/src/routes/github.ts +18 -2
  89. package/src/routes/install.ts +7 -1
  90. package/src/routes/integration-features.ts +258 -0
  91. package/src/routes/memory-slack-publications.ts +216 -0
  92. package/src/routes/packs.ts +437 -7
  93. package/src/routes/plugins.ts +751 -0
  94. package/src/routes/rigs.ts +77 -20
  95. package/src/routes/scheduled-tasks.ts +67 -40
  96. package/src/routes/sessions.ts +421 -245
  97. package/src/routes/skills.ts +174 -0
  98. package/src/routes/video-generation.ts +132 -0
  99. package/src/routes/workspaces.ts +46 -24
  100. package/src/sandbox/channel-a.ts +723 -116
  101. package/src/sandbox/metrics-ingestion.ts +121 -3
  102. package/src/sandbox/rematerialize.ts +35 -47
  103. package/src/sandbox/viewer.ts +29 -9
  104. package/src/temporal-schedule-cleanup.ts +135 -0
  105. package/dist/chunk-T4T2PGU4.js.map +0 -1
  106. package/dist/mcp/toolspace.d.ts +0 -71
  107. package/src/mcp/toolspace.ts +0 -1190
@@ -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
 
@@ -879,7 +899,7 @@ export type MintTerminalStreamInput = {
879
899
  session: Session;
880
900
  /** The viewer holder / principal id the scoped token is minted for. */
881
901
  viewerId: string;
882
- /** 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
883
903
  * session may have no Modal group lease; omit and the selfhosted branch handles it. */
884
904
  lease?: LeaseSnapshot;
885
905
  /** Test seam: override how the box is re-established by id (see
@@ -947,7 +967,7 @@ export async function mintTerminalStream(
947
967
  }
948
968
 
949
969
  // GATE 2: the box must be live (the handshake never spins one up).
950
- if (!lease || (lease.liveness !== "warm" && lease.liveness !== "draining")) {
970
+ if (!lease || lease.liveness !== "warm") {
951
971
  return null;
952
972
  }
953
973
 
@@ -0,0 +1,135 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ claimTemporalScheduleCleanups,
4
+ settleTemporalScheduleCleanup,
5
+ type Database,
6
+ type TemporalScheduleCleanupClaim,
7
+ } from "@opengeni/db";
8
+ import type { Observability } from "@opengeni/observability";
9
+
10
+ const CLEANUP_BATCH_SIZE = 32;
11
+ const CLEANUP_CLAIM_SECONDS = 15;
12
+ const CLEANUP_POLL_INTERVAL_MS = 1_000;
13
+
14
+ type TemporalScheduleCleanupDependencies = {
15
+ db: Database;
16
+ deleteSchedule: (temporalScheduleId: string) => Promise<void>;
17
+ observability?: Pick<Observability, "info" | "warn" | "error">;
18
+ };
19
+
20
+ export type TemporalScheduleCleanupBatchResult = {
21
+ claimed: number;
22
+ deleted: number;
23
+ failed: number;
24
+ stale: number;
25
+ };
26
+
27
+ function cleanupError(error: unknown): string {
28
+ if (error instanceof Error) {
29
+ return `${error.name}: ${error.message}`.slice(0, 2000);
30
+ }
31
+ return String(error).slice(0, 2000);
32
+ }
33
+
34
+ /** Settle exact claims concurrently; every external delete is idempotent. */
35
+ export async function processTemporalScheduleCleanupClaims(
36
+ deps: TemporalScheduleCleanupDependencies,
37
+ claims: readonly TemporalScheduleCleanupClaim[],
38
+ ): Promise<TemporalScheduleCleanupBatchResult> {
39
+ const results = await Promise.all(
40
+ claims.map(async (claim): Promise<"deleted" | "failed" | "stale"> => {
41
+ try {
42
+ await deps.deleteSchedule(claim.temporalScheduleId);
43
+ const settled = await settleTemporalScheduleCleanup(deps.db, {
44
+ id: claim.id,
45
+ claimId: claim.claimId,
46
+ });
47
+ return settled ? "deleted" : "stale";
48
+ } catch (error) {
49
+ const message = cleanupError(error);
50
+ try {
51
+ const settled = await settleTemporalScheduleCleanup(deps.db, {
52
+ id: claim.id,
53
+ claimId: claim.claimId,
54
+ error: message,
55
+ });
56
+ if (!settled) return "stale";
57
+ } catch (settlementError) {
58
+ deps.observability?.error("Temporal schedule cleanup settlement failed", {
59
+ cleanupId: claim.id,
60
+ temporalScheduleId: claim.temporalScheduleId,
61
+ attemptCount: claim.attemptCount,
62
+ error: cleanupError(settlementError),
63
+ });
64
+ }
65
+ deps.observability?.warn("Temporal schedule cleanup will retry", {
66
+ cleanupId: claim.id,
67
+ temporalScheduleId: claim.temporalScheduleId,
68
+ attemptCount: claim.attemptCount,
69
+ error: message,
70
+ });
71
+ return "failed";
72
+ }
73
+ }),
74
+ );
75
+
76
+ return {
77
+ claimed: claims.length,
78
+ deleted: results.filter((result) => result === "deleted").length,
79
+ failed: results.filter((result) => result === "failed").length,
80
+ stale: results.filter((result) => result === "stale").length,
81
+ };
82
+ }
83
+
84
+ /** Claim and process one globally bounded batch. */
85
+ export async function drainTemporalScheduleCleanupOutbox(
86
+ deps: TemporalScheduleCleanupDependencies,
87
+ ): Promise<TemporalScheduleCleanupBatchResult> {
88
+ const claims = await claimTemporalScheduleCleanups(deps.db, {
89
+ claimId: randomUUID(),
90
+ limit: CLEANUP_BATCH_SIZE,
91
+ claimSeconds: CLEANUP_CLAIM_SECONDS,
92
+ });
93
+ return await processTemporalScheduleCleanupClaims(deps, claims);
94
+ }
95
+
96
+ /**
97
+ * Start one non-overlapping reconciliation loop. PostgreSQL claims coordinate
98
+ * replicas; an API crash merely delays the exact row until its short claim
99
+ * expires.
100
+ */
101
+ export function startTemporalScheduleCleanupPump(
102
+ deps: TemporalScheduleCleanupDependencies,
103
+ options: { intervalMs?: number } = {},
104
+ ): () => Promise<void> {
105
+ const intervalMs = Math.max(100, options.intervalMs ?? CLEANUP_POLL_INTERVAL_MS);
106
+ let stopped = false;
107
+ let running: Promise<void> | undefined;
108
+
109
+ const tick = (): void => {
110
+ if (stopped || running) return;
111
+ running = drainTemporalScheduleCleanupOutbox(deps)
112
+ .then((result) => {
113
+ if (result.claimed > 0) {
114
+ deps.observability?.info("Temporal schedule cleanup batch settled", result);
115
+ }
116
+ })
117
+ .catch((error: unknown) => {
118
+ deps.observability?.error("Temporal schedule cleanup claim failed", {
119
+ error: cleanupError(error),
120
+ });
121
+ })
122
+ .finally(() => {
123
+ running = undefined;
124
+ });
125
+ };
126
+
127
+ const timer = setInterval(tick, intervalMs);
128
+ queueMicrotask(tick);
129
+
130
+ return async () => {
131
+ stopped = true;
132
+ clearInterval(timer);
133
+ await running;
134
+ };
135
+ }