@opengeni/db 0.28.9 → 0.36.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 (225) hide show
  1. package/dist/attached-browser-devices.d.ts +42 -0
  2. package/dist/attempt-tool-catalogs.d.ts +16 -0
  3. package/dist/browser-auth.d.ts +299 -0
  4. package/dist/browser-downloads.d.ts +51 -0
  5. package/dist/browser-identities.d.ts +137 -0
  6. package/dist/browser-sessions.d.ts +354 -0
  7. package/dist/browser-state-artifacts.d.ts +91 -0
  8. package/dist/canonical-human-identities.d.ts +48 -0
  9. package/dist/canonical-human-identities.js +28 -0
  10. package/dist/canonical-human-identities.js.map +1 -0
  11. package/dist/capability-components.d.ts +37 -0
  12. package/dist/capability-integrations.d.ts +194 -0
  13. package/dist/chunk-5KTIEDX7.js +13200 -0
  14. package/dist/chunk-5KTIEDX7.js.map +1 -0
  15. package/dist/chunk-CQZ4QSP5.js +995 -0
  16. package/dist/chunk-CQZ4QSP5.js.map +1 -0
  17. package/dist/chunk-F32YT7MP.js +117 -0
  18. package/dist/chunk-F32YT7MP.js.map +1 -0
  19. package/dist/chunk-JC5QPLUM.js +666 -0
  20. package/dist/chunk-JC5QPLUM.js.map +1 -0
  21. package/dist/chunk-P3RE4D2B.js +3535 -0
  22. package/dist/chunk-P3RE4D2B.js.map +1 -0
  23. package/dist/chunk-TAUTWOS5.js +259 -0
  24. package/dist/chunk-TAUTWOS5.js.map +1 -0
  25. package/dist/chunk-VKLB2ZGI.js +2389 -0
  26. package/dist/chunk-VKLB2ZGI.js.map +1 -0
  27. package/dist/codemode-operations.d.ts +117 -0
  28. package/dist/codex-token-resolver.d.ts +2 -2
  29. package/dist/company-profile-schema.d.ts +798 -0
  30. package/dist/company-profile.d.ts +268 -0
  31. package/dist/computer-sessions.d.ts +109 -0
  32. package/dist/connection-token-resolver.d.ts +9 -4
  33. package/dist/database.d.ts +57 -3
  34. package/dist/editable-artifact-durable-export.d.ts +153 -0
  35. package/dist/editable-artifact-durable-export.js +788 -0
  36. package/dist/editable-artifact-durable-export.js.map +1 -0
  37. package/dist/editable-artifact-materialization.d.ts +123 -0
  38. package/dist/editable-artifacts-schema.d.ts +4152 -0
  39. package/dist/editable-artifacts.d.ts +610 -0
  40. package/dist/editable-artifacts.js +26 -0
  41. package/dist/editable-artifacts.js.map +1 -0
  42. package/dist/generated-images.d.ts +139 -0
  43. package/dist/index.d.ts +1122 -126
  44. package/dist/index.js +42329 -18407
  45. package/dist/index.js.map +1 -1
  46. package/dist/insights.d.ts +73 -10
  47. package/dist/integration-bindings.d.ts +70 -0
  48. package/dist/integration-facets.d.ts +99 -0
  49. package/dist/interaction-revisions.d.ts +14 -0
  50. package/dist/interaction-schema.d.ts +6036 -0
  51. package/dist/knowledge-source-sync-schema.d.ts +1679 -0
  52. package/dist/knowledge-source-sync.d.ts +274 -0
  53. package/dist/memory-governance.d.ts +2 -2
  54. package/dist/memory-slack-delivery.d.ts +101 -0
  55. package/dist/new-session-drafts.d.ts +2 -2
  56. package/dist/pack-components.d.ts +76 -0
  57. package/dist/pack-installations.d.ts +86 -0
  58. package/dist/persistence-errors.d.ts +3 -3
  59. package/dist/plugin-packages.d.ts +126 -0
  60. package/dist/preference-registry.d.ts +1 -1
  61. package/dist/provision-roles.d.ts +9 -1
  62. package/dist/provision-roles.js +1 -1
  63. package/dist/runtime-posture.d.ts +19 -6
  64. package/dist/schema.d.ts +22654 -9359
  65. package/dist/schema.js +209 -3
  66. package/dist/scoped-knowledge.d.ts +97 -1
  67. package/dist/session-control.d.ts +33 -7
  68. package/dist/session-queue-commands.d.ts +19 -11
  69. package/dist/session-realtime-context.d.ts +2 -2
  70. package/dist/session-realtime-ledger.d.ts +4 -4
  71. package/dist/session-realtime-mirror.d.ts +2 -2
  72. package/dist/session-realtime-state.d.ts +1 -1
  73. package/dist/session-realtime-terminal.d.ts +1 -1
  74. package/dist/session-realtime.d.ts +7 -7
  75. package/dist/session-tenancy.d.ts +44 -0
  76. package/dist/session-tenancy.js +16 -0
  77. package/dist/session-tenancy.js.map +1 -0
  78. package/dist/session-tool-call-settlement.d.ts +1 -1
  79. package/dist/slack-task-policy-schema.d.ts +847 -0
  80. package/dist/slack-task-policy.d.ts +65 -0
  81. package/dist/slack-user-link-access.d.ts +54 -0
  82. package/dist/task-notes-schema.d.ts +924 -0
  83. package/dist/task-notes.d.ts +85 -0
  84. package/dist/transcription-recordings.d.ts +2 -2
  85. package/dist/turn-initiator.d.ts +2 -2
  86. package/dist/video-generation.d.ts +225 -0
  87. package/dist/video-generation.js +68 -0
  88. package/dist/video-generation.js.map +1 -0
  89. package/dist/workspace-artifacts.d.ts +1 -1
  90. package/dist/workspace-instruction-policies.d.ts +1 -1
  91. package/dist/workspace-learning-policy-schema.d.ts +932 -0
  92. package/dist/workspace-learning-policy.d.ts +161 -0
  93. package/dist/xai-subscription.d.ts +326 -0
  94. package/drizzle/0183_model_call_provider_cost_estimates.sql +39 -0
  95. package/drizzle/0184_sandbox_drain_teardown_fence.sql +682 -0
  96. package/drizzle/0185_temporal_schedule_cleanup_outbox.sql +168 -0
  97. package/drizzle/0186_sandbox_capture_provider_contract.sql +400 -0
  98. package/drizzle/0187_generated_image_artifacts.sql +173 -0
  99. package/drizzle/0188_image_generation_retention_failure.sql +25 -0
  100. package/drizzle/0189_retained_session_image_formats.sql +13 -0
  101. package/drizzle/0190_timeline_annotations.sql +31 -0
  102. package/drizzle/0191_editable_artifact_engine.sql +3853 -0
  103. package/drizzle/0192_editable_artifact_live_tickets.sql +338 -0
  104. package/drizzle/0193_editable_artifact_authorization.sql +190 -0
  105. package/drizzle/0194_editable_artifact_durable_exports.sql +160 -0
  106. package/drizzle/0195_editable_artifact_import_authorization.sql +203 -0
  107. package/drizzle/0196_rig_provider_images.sql +152 -0
  108. package/drizzle/0197_knowledge_source_sync_schedules.sql +523 -0
  109. package/drizzle/0198_memory_slack_publication_delivery.sql +393 -0
  110. package/drizzle/0199_workspace_learning_policy.sql +980 -0
  111. package/drizzle/0201_company_profile_authority.sql +849 -0
  112. package/drizzle/0202_document_index_checkpoints.sql +85 -0
  113. package/drizzle/0203_durable_video_generation.sql +353 -0
  114. package/drizzle/0204_video_generation_funding.sql +44 -0
  115. package/drizzle/0205_attempt_tool_catalogs.sql +230 -0
  116. package/drizzle/0206_browser_sessions.sql +646 -0
  117. package/drizzle/0207_browser_identities.sql +379 -0
  118. package/drizzle/0208_attached_browser_devices.sql +165 -0
  119. package/drizzle/0209_computer_sessions.sql +575 -0
  120. package/drizzle/0210_browser_auth_network_interventions.sql +441 -0
  121. package/drizzle/0211_editable_artifact_session_links.sql +58 -0
  122. package/drizzle/0212_browser_state_transfer_hardening.sql +560 -0
  123. package/drizzle/0212_slack_installation_bindings.sql +348 -0
  124. package/drizzle/0213_browser_interaction_authority.sql +67 -0
  125. package/drizzle/0213_slack_user_link_access_requests.sql +177 -0
  126. package/drizzle/0214_browser_download_saves.sql +34 -0
  127. package/drizzle/0214_session_activity_commit_gate.sql +262 -0
  128. package/drizzle/0215_browser_controller_host.sql +385 -0
  129. package/drizzle/0215_capabilities_platform.sql +980 -0
  130. package/drizzle/0216_browser_auth_health_evidence.sql +137 -0
  131. package/drizzle/0216_pack_component_ownership.sql +235 -0
  132. package/drizzle/0217_capability_definition_delete_authority.sql +32 -0
  133. package/drizzle/0217_external_browser_auth_operations.sql +22 -0
  134. package/drizzle/0218_organization_tenancy_foundation.sql +299 -0
  135. package/drizzle/0219_organization_tenancy_managed_human_provisioning.sql +317 -0
  136. package/drizzle/0219_site_auth_maintenance_sessions.sql +234 -0
  137. package/drizzle/0220_memory_slack_append_only_cascade.sql +38 -0
  138. package/drizzle/0220_session_channels.sql +54 -0
  139. package/drizzle/0221_sessions_channel_index.sql +5 -0
  140. package/drizzle/0222_session_visibility_authority_epochs.sql +158 -0
  141. package/drizzle/0222_sessions_channel_fk.sql +89 -0
  142. package/drizzle/0223_pending_tool_event_output.sql +55 -0
  143. package/drizzle/0223_sessions_channel_fk_validate.sql +118 -0
  144. package/drizzle/0224_slack_post_outcome_reconciliation.sql +123 -0
  145. package/drizzle/0225_session_visibility_fork_activation.sql +1363 -0
  146. package/drizzle/0226_personal_codex_authority_foundation.sql +365 -0
  147. package/drizzle/0227_slack_native_actions.sql +145 -0
  148. package/drizzle/0228_interaction_controller_data_plane.sql +14 -0
  149. package/drizzle/0228_slack_task_policy.sql +400 -0
  150. package/drizzle/0229_slack_inbox_file_fact.sql +10 -0
  151. package/drizzle/0230_user_scoped_variable_sets_rigs.sql +84 -0
  152. package/drizzle/0231_integration_definition_identity_cutover.sql +271 -0
  153. package/drizzle/0232_integration_facet_authority_cutover.sql +610 -0
  154. package/drizzle/0233_skill_and_integration_authority_cutover.sql +1162 -0
  155. package/drizzle/0234_xai_subscription_authority.sql +1139 -0
  156. package/drizzle/0235_canonical_human_login_bindings.sql +920 -0
  157. package/drizzle/0236_browser_identity_lifecycle.sql +39 -0
  158. package/drizzle/0236_session_visibility_slack_policy.sql +72 -0
  159. package/drizzle/0237_interaction_transition_reaper.sql +263 -0
  160. package/drizzle/0238_goal_persistence_policy.sql +471 -0
  161. package/drizzle/0238_supergrok_realtime_model.sql +29 -0
  162. package/drizzle/0239_supergrok_video_funding.sql +50 -0
  163. package/drizzle/0239_task_tree_notes.sql +990 -0
  164. package/package.json +26 -5
  165. package/src/attached-browser-devices.ts +371 -0
  166. package/src/attempt-tool-catalogs.ts +131 -0
  167. package/src/browser-auth.ts +4373 -0
  168. package/src/browser-downloads.ts +517 -0
  169. package/src/browser-identities.ts +1385 -0
  170. package/src/browser-sessions.ts +2294 -0
  171. package/src/browser-state-artifacts.ts +449 -0
  172. package/src/canonical-human-identities.ts +317 -0
  173. package/src/capability-components.ts +166 -0
  174. package/src/capability-integrations.ts +1571 -0
  175. package/src/codemode-operations.ts +674 -0
  176. package/src/company-profile-schema.ts +143 -0
  177. package/src/company-profile.ts +968 -0
  178. package/src/computer-sessions.ts +1130 -0
  179. package/src/connection-token-resolver.ts +270 -25
  180. package/src/database.ts +468 -24
  181. package/src/editable-artifact-durable-export.ts +1201 -0
  182. package/src/editable-artifact-materialization.ts +893 -0
  183. package/src/editable-artifacts-schema.ts +879 -0
  184. package/src/editable-artifacts.ts +5404 -0
  185. package/src/generated-images.ts +740 -0
  186. package/src/index.ts +9963 -1465
  187. package/src/insights.ts +251 -23
  188. package/src/integration-bindings.ts +455 -0
  189. package/src/integration-facets.ts +862 -0
  190. package/src/interaction-revisions.ts +70 -0
  191. package/src/interaction-schema.ts +1606 -0
  192. package/src/knowledge-source-sync-schema.ts +192 -0
  193. package/src/knowledge-source-sync.ts +1922 -0
  194. package/src/memory-slack-delivery.ts +801 -0
  195. package/src/pack-components.ts +954 -0
  196. package/src/pack-installations.ts +886 -0
  197. package/src/persistence-errors.ts +4 -7
  198. package/src/plugin-packages.ts +1186 -0
  199. package/src/provision-roles.ts +534 -1
  200. package/src/runtime-posture-cli.ts +9 -0
  201. package/src/runtime-posture.ts +721 -5
  202. package/src/schema.ts +6270 -3159
  203. package/src/scoped-knowledge-schema.ts +1 -1
  204. package/src/scoped-knowledge.ts +655 -3
  205. package/src/session-control.ts +130 -42
  206. package/src/session-queue-commands.ts +265 -15
  207. package/src/session-realtime-context.ts +2 -2
  208. package/src/session-realtime-ledger.ts +3 -3
  209. package/src/session-realtime.ts +7 -7
  210. package/src/session-tenancy.ts +188 -0
  211. package/src/session-tool-call-settlement.ts +18 -3
  212. package/src/slack-task-policy-schema.ts +130 -0
  213. package/src/slack-task-policy.ts +341 -0
  214. package/src/slack-user-link-access.ts +702 -0
  215. package/src/task-notes-schema.ts +135 -0
  216. package/src/task-notes.ts +178 -0
  217. package/src/video-generation.ts +1530 -0
  218. package/src/workspace-artifacts.ts +1 -1
  219. package/src/workspace-learning-policy-schema.ts +139 -0
  220. package/src/workspace-learning-policy.ts +595 -0
  221. package/src/xai-subscription.ts +1887 -0
  222. package/dist/chunk-P6CUHXQZ.js +0 -7248
  223. package/dist/chunk-P6CUHXQZ.js.map +0 -1
  224. package/dist/chunk-VHBFHMPC.js +0 -1219
  225. package/dist/chunk-VHBFHMPC.js.map +0 -1
@@ -0,0 +1,2294 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ BrowserSession,
4
+ BrowserSessionCapabilities,
5
+ BrowserSessionListResponse,
6
+ BrowserSessionMutationResponse,
7
+ BrowserRevisionMaterialization,
8
+ InteractionControllerBinding,
9
+ InteractionError,
10
+ InteractionLifecycleOperationReceipt,
11
+ type BrowserSessionCapabilities as BrowserSessionCapabilitiesValue,
12
+ type BrowserRevisionMaterialization as BrowserRevisionMaterializationValue,
13
+ type BrowserSessionListResponse as BrowserSessionListResponseValue,
14
+ type BrowserSessionMutationResponse as BrowserSessionMutationResponseValue,
15
+ type InteractionError as InteractionErrorValue,
16
+ type InteractionLifecycleOperationKind,
17
+ type InteractionLifecycleOperationReceipt as InteractionLifecycleOperationReceiptValue,
18
+ type InteractionPlacement,
19
+ NetworkRouteConfiguration,
20
+ NetworkRouteConsistency,
21
+ networkRoutePlacementCompatibilityIssue,
22
+ type NetworkRouteConfiguration as NetworkRouteConfigurationValue,
23
+ type NetworkRouteConsistency as NetworkRouteConsistencyValue,
24
+ } from "@opengeni/contracts";
25
+ import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
26
+ import { type Database, withRlsContext } from "./database";
27
+ import {
28
+ advanceWorkspaceInteractionRevision,
29
+ readWorkspaceInteractionRevision,
30
+ } from "./interaction-revisions";
31
+ import { safeDatabaseErrorFacts } from "./persistence-errors";
32
+ import {
33
+ commitBrowserStateUploadInTransaction,
34
+ markBrowserStateUploadsDeletePendingInTransaction,
35
+ prepareBrowserStateUploadInTransaction,
36
+ validateBrowserStateArtifactCommitInput,
37
+ type BrowserStateArtifactCommitInput,
38
+ } from "./browser-state-artifacts";
39
+ import * as schema from "./schema";
40
+
41
+ type BrowserSessionRow = typeof schema.browserSessions.$inferSelect;
42
+ type BrowserAssociationRow = typeof schema.browserSessionAssociations.$inferSelect;
43
+ type InteractionOperationRow = typeof schema.interactionOperations.$inferSelect;
44
+
45
+ const CONSISTENT_READ = {
46
+ isolationLevel: "repeatable read",
47
+ accessMode: "read only",
48
+ } as const;
49
+
50
+ export const MANAGED_BROWSER_SESSION_CAPABILITIES = BrowserSessionCapabilities.parse({
51
+ semanticObservation: true,
52
+ screenshots: true,
53
+ liveFrames: true,
54
+ humanInput: true,
55
+ tabs: true,
56
+ downloads: true,
57
+ uploads: true,
58
+ clipboard: true,
59
+ permissions: true,
60
+ diagnostics: true,
61
+ rawCdp: false,
62
+ linkedComputer: false,
63
+ privateCheckpoint: true,
64
+ identityPublication: true,
65
+ parallelTargets: true,
66
+ });
67
+
68
+ export const ATTACHED_BROWSER_SESSION_CAPABILITIES = BrowserSessionCapabilities.parse({
69
+ semanticObservation: true,
70
+ screenshots: true,
71
+ liveFrames: true,
72
+ humanInput: true,
73
+ tabs: true,
74
+ downloads: false,
75
+ uploads: false,
76
+ clipboard: true,
77
+ permissions: false,
78
+ diagnostics: true,
79
+ rawCdp: false,
80
+ linkedComputer: false,
81
+ privateCheckpoint: false,
82
+ identityPublication: false,
83
+ parallelTargets: true,
84
+ });
85
+
86
+ /** Remote Chromium controlled through the same native CDP driver. Files and
87
+ * portable profile state remain false because their bytes live on another host. */
88
+ export const EXTERNAL_BROWSER_SESSION_CAPABILITIES = BrowserSessionCapabilities.parse({
89
+ semanticObservation: true,
90
+ screenshots: true,
91
+ liveFrames: true,
92
+ humanInput: true,
93
+ tabs: true,
94
+ downloads: false,
95
+ uploads: false,
96
+ clipboard: true,
97
+ permissions: true,
98
+ diagnostics: true,
99
+ rawCdp: false,
100
+ linkedComputer: false,
101
+ privateCheckpoint: false,
102
+ identityPublication: false,
103
+ parallelTargets: true,
104
+ });
105
+
106
+ /** Measured against Lightpanda 0.3.5. Unsupported Chromium-only affordances
107
+ * remain false instead of being emulated or silently routed elsewhere. */
108
+ export const LIGHTPANDA_BROWSER_SESSION_CAPABILITIES = BrowserSessionCapabilities.parse({
109
+ semanticObservation: true,
110
+ screenshots: true,
111
+ liveFrames: false,
112
+ humanInput: false,
113
+ tabs: false,
114
+ downloads: false,
115
+ uploads: true,
116
+ clipboard: false,
117
+ permissions: false,
118
+ diagnostics: true,
119
+ rawCdp: false,
120
+ linkedComputer: false,
121
+ privateCheckpoint: false,
122
+ identityPublication: false,
123
+ parallelTargets: false,
124
+ });
125
+
126
+ export class BrowserSessionNotFoundError extends Error {
127
+ readonly name = "BrowserSessionNotFoundError";
128
+ }
129
+
130
+ export class BrowserSessionOperationConflictError extends Error {
131
+ readonly name = "BrowserSessionOperationConflictError";
132
+ }
133
+
134
+ export class BrowserSessionStateError extends Error {
135
+ readonly name = "BrowserSessionStateError";
136
+ }
137
+
138
+ export type PrepareBrowserSessionCreateInput = {
139
+ accountId: string;
140
+ workspaceId: string;
141
+ operationId: string;
142
+ associatedSessionId: string;
143
+ actorSubjectId: string;
144
+ name: string;
145
+ initialUrl: string | null;
146
+ placement: InteractionPlacement;
147
+ driverId: string;
148
+ engine: BrowserSessionRow["engine"];
149
+ headless: boolean;
150
+ identityId: string | null;
151
+ baseRevisionId: string | null;
152
+ networkRouteId?: string | null;
153
+ linkedComputerSessionId?: string | null;
154
+ resolveDefaultRevision?: boolean;
155
+ capabilities?: BrowserSessionCapabilitiesValue;
156
+ };
157
+
158
+ export type PrepareBrowserSessionEndInput = {
159
+ accountId: string;
160
+ workspaceId: string;
161
+ browserSessionId: string;
162
+ operationId: string;
163
+ actorSubjectId: string;
164
+ };
165
+
166
+ export type PrepareBrowserSessionLifecycleInput = PrepareBrowserSessionEndInput;
167
+
168
+ export type BrowserPrivateCheckpointAuthority = {
169
+ artifactId: string;
170
+ sourceBrowserSessionId: string;
171
+ objectKey: string;
172
+ encryptedDataKey: string;
173
+ format: string;
174
+ artifactDigest: string;
175
+ contentDigest: string;
176
+ manifestDigest: string;
177
+ sizeBytes: number;
178
+ materialization: BrowserRevisionMaterializationValue;
179
+ };
180
+
181
+ /** Internal executor view of one BrowserSession. Plaintext controller
182
+ * credentials are deliberately absent; callers derive them from this fenced
183
+ * durable state. */
184
+ export type BrowserSessionControlRecord = {
185
+ session: BrowserSession;
186
+ tokenGeneration: number;
187
+ sourceSessionId: string;
188
+ /** Placement hosting browserd. It equals the logical sandbox placement for
189
+ * managed browsers and remains the source home sandbox for remote browsers. */
190
+ controllerHostSandboxGroupId: string | null;
191
+ createOperationId: string;
192
+ networkRouteAuthority: BrowserSessionNetworkRouteAuthority | null;
193
+ operation: null | {
194
+ operationId: string;
195
+ kind: InteractionLifecycleOperationKind;
196
+ state: InteractionOperationRow["state"];
197
+ controllerGeneration: string | null;
198
+ actorSubjectId: string;
199
+ };
200
+ };
201
+
202
+ export type BrowserSessionNetworkRouteAuthority = {
203
+ routeId: string;
204
+ routeVersion: number;
205
+ credentialVersion: number | null;
206
+ authorityDigest: string | null;
207
+ configuration: NetworkRouteConfigurationValue;
208
+ consistency: NetworkRouteConsistencyValue;
209
+ };
210
+
211
+ function iso(value: Date): string {
212
+ return value.toISOString();
213
+ }
214
+
215
+ function safeInteger(value: number, label: string): number {
216
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} is outside safe range`);
217
+ return value;
218
+ }
219
+
220
+ function placementFromRow(row: BrowserSessionRow): InteractionPlacement {
221
+ switch (row.placementKind) {
222
+ case "sandbox_group":
223
+ if (!row.sandboxGroupId) throw new Error("BrowserSession sandbox placement is incomplete");
224
+ return { kind: "sandbox_group", sandboxGroupId: row.sandboxGroupId };
225
+ case "connected_machine":
226
+ if (!row.connectedSandboxId) {
227
+ throw new Error("BrowserSession connected-machine placement is incomplete");
228
+ }
229
+ return { kind: "connected_machine", sandboxId: row.connectedSandboxId };
230
+ case "attached_device":
231
+ if (!row.deviceId) throw new Error("BrowserSession attached-device placement is incomplete");
232
+ return { kind: "attached_device", deviceId: row.deviceId };
233
+ case "external_provider":
234
+ if (!row.externalProviderId || !row.externalPlacementId) {
235
+ throw new Error("BrowserSession external placement is incomplete");
236
+ }
237
+ return {
238
+ kind: "external_provider",
239
+ providerId: row.externalProviderId,
240
+ placementId: row.externalPlacementId,
241
+ };
242
+ }
243
+ }
244
+
245
+ function controllerFromRow(row: BrowserSessionRow): InteractionControllerBinding | null {
246
+ if (!row.controllerId && !row.controllerGeneration && !row.placementInstanceId) return null;
247
+ if (!row.controllerId || !row.controllerGeneration || !row.placementInstanceId) {
248
+ throw new Error("BrowserSession controller binding is incomplete");
249
+ }
250
+ return {
251
+ controllerId: row.controllerId,
252
+ controllerGeneration: row.controllerGeneration,
253
+ placementInstanceId: row.placementInstanceId,
254
+ };
255
+ }
256
+
257
+ function associationFromRow(row: BrowserAssociationRow) {
258
+ return {
259
+ sessionId: row.sessionId,
260
+ turnId: row.turnId,
261
+ attemptId: row.attemptId,
262
+ relationship: row.relationship,
263
+ actorSubjectId: row.actorSubjectId,
264
+ lastUsedAt: iso(row.lastUsedAt),
265
+ } as const;
266
+ }
267
+
268
+ function browserSessionFromRows(
269
+ row: BrowserSessionRow,
270
+ associations: readonly BrowserAssociationRow[],
271
+ ) {
272
+ return BrowserSession.parse({
273
+ id: row.id,
274
+ accountId: row.accountId,
275
+ workspaceId: row.workspaceId,
276
+ name: row.name,
277
+ lifecycle: row.lifecycle,
278
+ placement: placementFromRow(row),
279
+ controller: controllerFromRow(row),
280
+ driverId: row.driverId,
281
+ engine: row.engine,
282
+ engineVersion: row.engineVersion,
283
+ headless: row.headless,
284
+ identityId: row.identityId,
285
+ baseRevisionId: row.baseRevisionId,
286
+ networkRouteId: row.networkRouteId,
287
+ linkedComputerSessionId: row.linkedComputerSessionId,
288
+ capabilities: row.capabilities,
289
+ associations: associations.map(associationFromRow),
290
+ createdBySubjectId: row.createdBySubjectId,
291
+ createdAt: iso(row.createdAt),
292
+ lastUsedAt: iso(row.lastUsedAt),
293
+ failureCode: row.failureCode,
294
+ });
295
+ }
296
+
297
+ function operationReceipt(
298
+ row: InteractionOperationRow,
299
+ replayed: boolean,
300
+ ): InteractionLifecycleOperationReceiptValue {
301
+ const error: InteractionErrorValue | null = row.errorCode
302
+ ? InteractionError.parse({
303
+ code: row.errorCode,
304
+ message: row.errorMessage,
305
+ retryable: row.errorRetryable,
306
+ ...(row.errorDetails ? { details: row.errorDetails } : {}),
307
+ })
308
+ : null;
309
+ return InteractionLifecycleOperationReceipt.parse({
310
+ operationId: row.operationId,
311
+ resourceKind: row.resourceKind,
312
+ resourceId: row.resourceId,
313
+ kind: row.kind,
314
+ state: row.state,
315
+ replayed,
316
+ error,
317
+ createdAt: iso(row.createdAt),
318
+ dispatchedAt: row.dispatchedAt ? iso(row.dispatchedAt) : null,
319
+ settledAt: row.settledAt ? iso(row.settledAt) : null,
320
+ });
321
+ }
322
+
323
+ async function loadAssociations(
324
+ db: Database,
325
+ workspaceId: string,
326
+ browserSessionIds: readonly string[],
327
+ ): Promise<BrowserAssociationRow[]> {
328
+ if (browserSessionIds.length === 0) return [];
329
+ return await db
330
+ .select()
331
+ .from(schema.browserSessionAssociations)
332
+ .where(
333
+ and(
334
+ eq(schema.browserSessionAssociations.workspaceId, workspaceId),
335
+ inArray(schema.browserSessionAssociations.browserSessionId, [...browserSessionIds]),
336
+ ),
337
+ )
338
+ .orderBy(
339
+ desc(schema.browserSessionAssociations.lastUsedAt),
340
+ desc(schema.browserSessionAssociations.createdAt),
341
+ );
342
+ }
343
+
344
+ async function loadBrowserSession(
345
+ db: Database,
346
+ workspaceId: string,
347
+ browserSessionId: string,
348
+ ): Promise<ReturnType<typeof browserSessionFromRows> | null> {
349
+ const [row] = await db
350
+ .select()
351
+ .from(schema.browserSessions)
352
+ .where(
353
+ and(
354
+ eq(schema.browserSessions.workspaceId, workspaceId),
355
+ eq(schema.browserSessions.id, browserSessionId),
356
+ ),
357
+ )
358
+ .limit(1);
359
+ if (!row) return null;
360
+ const associations = await loadAssociations(db, workspaceId, [row.id]);
361
+ return browserSessionFromRows(row, associations);
362
+ }
363
+
364
+ async function loadOperation(
365
+ db: Database,
366
+ workspaceId: string,
367
+ operationId: string,
368
+ ): Promise<InteractionOperationRow | null> {
369
+ const [row] = await db
370
+ .select()
371
+ .from(schema.interactionOperations)
372
+ .where(
373
+ and(
374
+ eq(schema.interactionOperations.workspaceId, workspaceId),
375
+ eq(schema.interactionOperations.operationId, operationId),
376
+ ),
377
+ )
378
+ .limit(1);
379
+ return row ?? null;
380
+ }
381
+
382
+ function requestDigest(value: Record<string, unknown>): string {
383
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
384
+ }
385
+
386
+ export function browserSessionCreateRequestDigest(input: PrepareBrowserSessionCreateInput): string {
387
+ const revisionSelection = input.identityId
388
+ ? input.resolveDefaultRevision
389
+ ? { kind: "identity_default" as const }
390
+ : input.baseRevisionId
391
+ ? { kind: "exact" as const, revisionId: input.baseRevisionId }
392
+ : { kind: "blank_identity" as const }
393
+ : { kind: "none" as const };
394
+ return requestDigest({
395
+ version: 4,
396
+ associatedSessionId: input.associatedSessionId,
397
+ name: input.name,
398
+ initialUrl: input.initialUrl,
399
+ placement: input.placement,
400
+ driverId: input.driverId,
401
+ engine: input.engine,
402
+ headless: input.headless,
403
+ identityId: input.identityId,
404
+ networkRouteId: input.networkRouteId ?? null,
405
+ linkedComputerSessionId: input.linkedComputerSessionId ?? null,
406
+ revisionSelection,
407
+ capabilities: normalizedBrowserCapabilities(input),
408
+ actorSubjectId: input.actorSubjectId,
409
+ });
410
+ }
411
+
412
+ function lifecycleRequestDigest(
413
+ browserSessionId: string,
414
+ kind: InteractionLifecycleOperationKind,
415
+ actorSubjectId: string,
416
+ ): string {
417
+ return requestDigest({ version: 1, browserSessionId, kind, actorSubjectId });
418
+ }
419
+
420
+ async function replayedMutation(
421
+ db: Database,
422
+ workspaceId: string,
423
+ operation: InteractionOperationRow,
424
+ expected: { kind: InteractionLifecycleOperationKind; digest: string },
425
+ ): Promise<BrowserSessionMutationResponseValue> {
426
+ if (
427
+ operation.resourceKind !== "browser_session" ||
428
+ operation.kind !== expected.kind ||
429
+ operation.requestDigest !== expected.digest
430
+ ) {
431
+ throw new BrowserSessionOperationConflictError(
432
+ "Interaction operation id is already bound to another request",
433
+ );
434
+ }
435
+ const session = await loadBrowserSession(db, workspaceId, operation.resourceId);
436
+ if (!session) throw new Error("Interaction operation references a missing BrowserSession");
437
+ return BrowserSessionMutationResponse.parse({
438
+ session,
439
+ operation: operationReceipt(operation, true),
440
+ });
441
+ }
442
+
443
+ export async function listBrowserSessions(
444
+ db: Database,
445
+ input: { accountId: string; workspaceId: string },
446
+ ): Promise<BrowserSessionListResponseValue> {
447
+ return await withRlsContext(
448
+ db,
449
+ input,
450
+ async (scopedDb) => {
451
+ const rows = await scopedDb
452
+ .select()
453
+ .from(schema.browserSessions)
454
+ .where(eq(schema.browserSessions.workspaceId, input.workspaceId))
455
+ .orderBy(desc(schema.browserSessions.lastUsedAt), desc(schema.browserSessions.id));
456
+ const associations = await loadAssociations(
457
+ scopedDb,
458
+ input.workspaceId,
459
+ rows.map((row) => row.id),
460
+ );
461
+ const associationsByResource = new Map<string, BrowserAssociationRow[]>();
462
+ for (const association of associations) {
463
+ const current = associationsByResource.get(association.browserSessionId) ?? [];
464
+ current.push(association);
465
+ associationsByResource.set(association.browserSessionId, current);
466
+ }
467
+ return BrowserSessionListResponse.parse({
468
+ revision: await readWorkspaceInteractionRevision(scopedDb, input.workspaceId),
469
+ sessions: rows.map((row) =>
470
+ browserSessionFromRows(row, associationsByResource.get(row.id) ?? []),
471
+ ),
472
+ });
473
+ },
474
+ CONSISTENT_READ,
475
+ );
476
+ }
477
+
478
+ export async function getBrowserSession(
479
+ db: Database,
480
+ input: { accountId: string; workspaceId: string; browserSessionId: string },
481
+ ) {
482
+ return await withRlsContext(
483
+ db,
484
+ input,
485
+ async (scopedDb) => {
486
+ const session = await loadBrowserSession(scopedDb, input.workspaceId, input.browserSessionId);
487
+ if (!session) throw new BrowserSessionNotFoundError("BrowserSession not found");
488
+ return session;
489
+ },
490
+ CONSISTENT_READ,
491
+ );
492
+ }
493
+
494
+ export async function getBrowserSessionControlRecord(
495
+ db: Database,
496
+ input: {
497
+ accountId: string;
498
+ workspaceId: string;
499
+ browserSessionId: string;
500
+ operationId?: string;
501
+ },
502
+ ): Promise<BrowserSessionControlRecord> {
503
+ return await withRlsContext(
504
+ db,
505
+ input,
506
+ async (scopedDb) => {
507
+ const [row] = await scopedDb
508
+ .select()
509
+ .from(schema.browserSessions)
510
+ .where(
511
+ and(
512
+ eq(schema.browserSessions.accountId, input.accountId),
513
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
514
+ eq(schema.browserSessions.id, input.browserSessionId),
515
+ ),
516
+ )
517
+ .limit(1);
518
+ if (!row) throw new BrowserSessionNotFoundError("BrowserSession not found");
519
+
520
+ const operation = input.operationId
521
+ ? await loadOperation(scopedDb, input.workspaceId, input.operationId)
522
+ : null;
523
+ if (input.operationId) assertOperationResource(operation, input.browserSessionId);
524
+ return await controlRecordFromRows(scopedDb, input.workspaceId, row, operation);
525
+ },
526
+ CONSISTENT_READ,
527
+ );
528
+ }
529
+
530
+ export async function getBrowserPrivateCheckpointAuthority(
531
+ db: Database,
532
+ input: { accountId: string; workspaceId: string; browserSessionId: string },
533
+ ): Promise<BrowserPrivateCheckpointAuthority | null> {
534
+ return await withRlsContext(
535
+ db,
536
+ input,
537
+ async (scopedDb) => {
538
+ const [session] = await scopedDb
539
+ .select({
540
+ id: schema.browserSessions.id,
541
+ artifactId: schema.browserSessions.privateCheckpointArtifactId,
542
+ })
543
+ .from(schema.browserSessions)
544
+ .where(
545
+ and(
546
+ eq(schema.browserSessions.accountId, input.accountId),
547
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
548
+ eq(schema.browserSessions.id, input.browserSessionId),
549
+ ),
550
+ )
551
+ .limit(1);
552
+ if (!session) throw new BrowserSessionNotFoundError("BrowserSession not found");
553
+ if (!session.artifactId) return null;
554
+ const [artifact] = await scopedDb
555
+ .select()
556
+ .from(schema.browserStateArtifacts)
557
+ .where(
558
+ and(
559
+ eq(schema.browserStateArtifacts.workspaceId, input.workspaceId),
560
+ eq(schema.browserStateArtifacts.id, session.artifactId),
561
+ ),
562
+ )
563
+ .limit(1);
564
+ if (
565
+ !artifact ||
566
+ artifact.sourceBrowserSessionId !== session.id ||
567
+ artifact.purpose !== "private_checkpoint" ||
568
+ artifact.state !== "available" ||
569
+ artifact.encryptedDataKey === null
570
+ ) {
571
+ throw new BrowserSessionStateError(
572
+ "BrowserSession private checkpoint authority is inconsistent",
573
+ );
574
+ }
575
+ return {
576
+ artifactId: artifact.id,
577
+ sourceBrowserSessionId: artifact.sourceBrowserSessionId,
578
+ objectKey: artifact.objectKey,
579
+ encryptedDataKey: artifact.encryptedDataKey,
580
+ format: artifact.format,
581
+ artifactDigest: artifact.artifactDigest,
582
+ contentDigest: artifact.contentDigest,
583
+ manifestDigest: artifact.manifestDigest,
584
+ sizeBytes: safeInteger(artifact.sizeBytes, "browser checkpoint artifact size"),
585
+ materialization: BrowserRevisionMaterialization.parse(artifact.materialization),
586
+ };
587
+ },
588
+ CONSISTENT_READ,
589
+ );
590
+ }
591
+
592
+ /** Resolve an idempotency key before inspecting mutable placement. This keeps
593
+ * a retried create bound to its original BrowserSession even if the source
594
+ * session's active sandbox pointer has since changed. */
595
+ export async function findBrowserSessionControlRecordByOperation(
596
+ db: Database,
597
+ input: { accountId: string; workspaceId: string; operationId: string },
598
+ ): Promise<BrowserSessionControlRecord | null> {
599
+ return await withRlsContext(
600
+ db,
601
+ input,
602
+ async (scopedDb) => {
603
+ const operation = await loadOperation(scopedDb, input.workspaceId, input.operationId);
604
+ if (!operation) return null;
605
+ if (operation.resourceKind !== "browser_session") {
606
+ throw new BrowserSessionOperationConflictError(
607
+ "Interaction operation is bound to another resource",
608
+ );
609
+ }
610
+ const [row] = await scopedDb
611
+ .select()
612
+ .from(schema.browserSessions)
613
+ .where(
614
+ and(
615
+ eq(schema.browserSessions.accountId, input.accountId),
616
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
617
+ eq(schema.browserSessions.id, operation.resourceId),
618
+ ),
619
+ )
620
+ .limit(1);
621
+ if (!row) throw new Error("BrowserSession operation references a missing resource");
622
+ return await controlRecordFromRows(scopedDb, input.workspaceId, row, operation);
623
+ },
624
+ CONSISTENT_READ,
625
+ );
626
+ }
627
+
628
+ async function controlRecordFromRows(
629
+ db: Database,
630
+ workspaceId: string,
631
+ row: BrowserSessionRow,
632
+ operation: InteractionOperationRow | null,
633
+ ): Promise<BrowserSessionControlRecord> {
634
+ const associations = await loadAssociations(db, workspaceId, [row.id]);
635
+ const createdAssociations = associations.filter(
636
+ (association) => association.relationship === "created",
637
+ );
638
+ if (createdAssociations.length !== 1) {
639
+ throw new Error("BrowserSession must have exactly one creation association");
640
+ }
641
+ return {
642
+ session: browserSessionFromRows(row, associations),
643
+ tokenGeneration: row.tokenGeneration,
644
+ sourceSessionId: createdAssociations[0]!.sessionId,
645
+ controllerHostSandboxGroupId: row.controllerHostSandboxGroupId,
646
+ createOperationId: row.createOperationId,
647
+ networkRouteAuthority: networkRouteAuthorityFromRow(row),
648
+ operation: operation
649
+ ? {
650
+ operationId: operation.operationId,
651
+ kind: operation.kind,
652
+ state: operation.state,
653
+ controllerGeneration: operation.controllerGeneration,
654
+ actorSubjectId: operation.actorSubjectId,
655
+ }
656
+ : null,
657
+ };
658
+ }
659
+
660
+ function networkRouteAuthorityFromRow(
661
+ row: BrowserSessionRow,
662
+ ): BrowserSessionNetworkRouteAuthority | null {
663
+ if (!row.networkRouteId) return null;
664
+ if (!row.networkRouteVersion || !row.networkRouteConfiguration || !row.networkRouteConsistency) {
665
+ throw new Error("BrowserSession network route is missing its pinned authority");
666
+ }
667
+ return {
668
+ routeId: row.networkRouteId,
669
+ routeVersion: row.networkRouteVersion,
670
+ credentialVersion: row.networkRouteCredentialVersion,
671
+ authorityDigest: row.networkRouteAuthorityDigest,
672
+ configuration: NetworkRouteConfiguration.parse(row.networkRouteConfiguration),
673
+ consistency: NetworkRouteConsistency.parse(row.networkRouteConsistency),
674
+ };
675
+ }
676
+
677
+ export async function prepareBrowserSessionCreate(
678
+ db: Database,
679
+ input: PrepareBrowserSessionCreateInput,
680
+ ): Promise<BrowserSessionMutationResponseValue> {
681
+ const digest = browserSessionCreateRequestDigest(input);
682
+ const browserSessionId = randomUUID();
683
+ const capabilities = normalizedBrowserCapabilities(input);
684
+ return await withRlsContext(
685
+ db,
686
+ input,
687
+ async (scopedDb) =>
688
+ await scopedDb.transaction(async (txRaw) => {
689
+ const tx = txRaw as unknown as Database;
690
+ await tx.execute(sql`
691
+ select id from workspaces
692
+ where id = ${input.workspaceId} and account_id = ${input.accountId}
693
+ for key share
694
+ `);
695
+ const existingOperation = await loadOperation(tx, input.workspaceId, input.operationId);
696
+ if (existingOperation) {
697
+ return await replayedMutation(tx, input.workspaceId, existingOperation, {
698
+ kind: "create",
699
+ digest,
700
+ });
701
+ }
702
+ const [sourceSession] = await tx
703
+ .select({ id: schema.sessions.id, sandboxGroupId: schema.sessions.sandboxGroupId })
704
+ .from(schema.sessions)
705
+ .where(
706
+ and(
707
+ eq(schema.sessions.workspaceId, input.workspaceId),
708
+ eq(schema.sessions.id, input.associatedSessionId),
709
+ ),
710
+ )
711
+ .limit(1);
712
+ if (!sourceSession) throw new BrowserSessionNotFoundError("Associated session not found");
713
+ await assertLinkedComputerAvailable(tx, input);
714
+ const networkRoute = await assertNetworkRouteAvailable(tx, input);
715
+ const identitySelection = await resolveCreateIdentitySelection(tx, input);
716
+
717
+ const [insertedOperation] = await tx
718
+ .insert(schema.interactionOperations)
719
+ .values({
720
+ operationId: input.operationId,
721
+ accountId: input.accountId,
722
+ workspaceId: input.workspaceId,
723
+ resourceKind: "browser_session",
724
+ resourceId: browserSessionId,
725
+ kind: "create",
726
+ requestDigest: digest,
727
+ state: "prepared",
728
+ actorSubjectId: input.actorSubjectId,
729
+ })
730
+ .onConflictDoNothing({
731
+ target: schema.interactionOperations.operationId,
732
+ })
733
+ .returning();
734
+ if (!insertedOperation) {
735
+ const existing = await loadOperation(tx, input.workspaceId, input.operationId);
736
+ if (!existing) {
737
+ throw new BrowserSessionOperationConflictError(
738
+ "Interaction operation id belongs to another workspace",
739
+ );
740
+ }
741
+ return await replayedMutation(tx, input.workspaceId, existing, {
742
+ kind: "create",
743
+ digest,
744
+ });
745
+ }
746
+
747
+ const placementColumns = placementToColumns(input.placement);
748
+ const controllerHostSandboxGroupId =
749
+ input.placement.kind === "sandbox_group"
750
+ ? input.placement.sandboxGroupId
751
+ : input.placement.kind === "external_provider"
752
+ ? sourceSession.sandboxGroupId
753
+ : null;
754
+ const [insertedSession] = await tx
755
+ .insert(schema.browserSessions)
756
+ .values({
757
+ id: browserSessionId,
758
+ accountId: input.accountId,
759
+ workspaceId: input.workspaceId,
760
+ name: input.name,
761
+ lifecycle: "starting",
762
+ ...placementColumns,
763
+ controllerHostSandboxGroupId,
764
+ driverId: input.driverId,
765
+ engine: input.engine,
766
+ headless: input.headless,
767
+ identityId: identitySelection.identityId,
768
+ baseRevisionId: identitySelection.baseRevisionId,
769
+ networkRouteId: input.networkRouteId ?? null,
770
+ networkRouteVersion: networkRoute?.version ?? null,
771
+ networkRouteConfiguration: networkRoute?.configuration ?? null,
772
+ networkRouteConsistency: networkRoute?.consistency ?? null,
773
+ linkedComputerSessionId: input.linkedComputerSessionId ?? null,
774
+ capabilities,
775
+ createOperationId: input.operationId,
776
+ createdBySubjectId: input.actorSubjectId,
777
+ })
778
+ .returning();
779
+ if (!insertedSession) throw new Error("BrowserSession insert did not return its row");
780
+ const [association] = await tx
781
+ .insert(schema.browserSessionAssociations)
782
+ .values({
783
+ accountId: input.accountId,
784
+ workspaceId: input.workspaceId,
785
+ browserSessionId,
786
+ sessionId: input.associatedSessionId,
787
+ turnId: null,
788
+ attemptId: null,
789
+ relationship: "created",
790
+ actorSubjectId: input.actorSubjectId,
791
+ })
792
+ .returning();
793
+ if (!association) throw new Error("BrowserSession association insert failed");
794
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
795
+ return BrowserSessionMutationResponse.parse({
796
+ session: browserSessionFromRows(insertedSession, [association]),
797
+ operation: operationReceipt(insertedOperation, false),
798
+ });
799
+ }),
800
+ );
801
+ }
802
+
803
+ /**
804
+ * Bind the broker-resolved, secret-free launch authority before controller
805
+ * dispatch. A prepared operation may refresh this binding while no physical
806
+ * controller owns it; after dispatch it is immutable and replay-fenced.
807
+ */
808
+ export async function bindBrowserSessionNetworkRouteAuthority(
809
+ db: Database,
810
+ input: {
811
+ accountId: string;
812
+ workspaceId: string;
813
+ browserSessionId: string;
814
+ operationId: string;
815
+ routeVersion: number;
816
+ credentialVersion: number | null;
817
+ authorityDigest: string;
818
+ },
819
+ ): Promise<BrowserSessionNetworkRouteAuthority> {
820
+ if (!Number.isSafeInteger(input.routeVersion) || input.routeVersion < 1) {
821
+ throw new BrowserSessionStateError("Network route version is invalid");
822
+ }
823
+ if (
824
+ input.credentialVersion !== null &&
825
+ (!Number.isSafeInteger(input.credentialVersion) || input.credentialVersion < 1)
826
+ ) {
827
+ throw new BrowserSessionStateError("Network route credential version is invalid");
828
+ }
829
+ if (!/^[A-Za-z0-9._~-]{16,256}$/u.test(input.authorityDigest)) {
830
+ throw new BrowserSessionStateError("Network route authority digest is invalid");
831
+ }
832
+ return await withRlsContext(
833
+ db,
834
+ input,
835
+ async (scopedDb) =>
836
+ await scopedDb.transaction(async (txRaw) => {
837
+ const tx = txRaw as unknown as Database;
838
+ await lockOperation(tx, input.workspaceId, input.operationId);
839
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
840
+ assertOperationResource(operation, input.browserSessionId);
841
+ if (operation!.kind !== "create" && operation!.kind !== "resume") {
842
+ throw new BrowserSessionOperationConflictError(
843
+ "Network route authority belongs to another browser operation",
844
+ );
845
+ }
846
+ const [current] = await tx
847
+ .select()
848
+ .from(schema.browserSessions)
849
+ .where(
850
+ and(
851
+ eq(schema.browserSessions.accountId, input.accountId),
852
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
853
+ eq(schema.browserSessions.id, input.browserSessionId),
854
+ ),
855
+ )
856
+ .for("update")
857
+ .limit(1);
858
+ if (!current) throw new BrowserSessionNotFoundError("BrowserSession not found");
859
+ if (
860
+ !current.networkRouteId ||
861
+ current.networkRouteVersion !== input.routeVersion ||
862
+ !current.networkRouteConfiguration ||
863
+ !current.networkRouteConsistency
864
+ ) {
865
+ throw new BrowserSessionOperationConflictError(
866
+ "BrowserSession route does not match this launch authority",
867
+ );
868
+ }
869
+ const configuration = NetworkRouteConfiguration.parse(current.networkRouteConfiguration);
870
+ const hasCredential = "credential" in configuration && configuration.credential !== null;
871
+ if (hasCredential !== (input.credentialVersion !== null)) {
872
+ throw new BrowserSessionStateError(
873
+ "Network route credential authority does not match its configuration",
874
+ );
875
+ }
876
+ if (
877
+ current.networkRouteAuthorityDigest === input.authorityDigest &&
878
+ current.networkRouteCredentialVersion === input.credentialVersion
879
+ ) {
880
+ return networkRouteAuthorityFromRow(current)!;
881
+ }
882
+ if (
883
+ operation!.state !== "prepared" ||
884
+ operation!.controllerGeneration !== null ||
885
+ current.controllerGeneration !== null
886
+ ) {
887
+ throw new BrowserSessionOperationConflictError(
888
+ "Dispatched BrowserSession route authority is immutable",
889
+ );
890
+ }
891
+ const [updated] = await tx
892
+ .update(schema.browserSessions)
893
+ .set({
894
+ networkRouteCredentialVersion: input.credentialVersion,
895
+ networkRouteAuthorityDigest: input.authorityDigest,
896
+ updatedAt: new Date(),
897
+ })
898
+ .where(
899
+ and(
900
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
901
+ eq(schema.browserSessions.id, input.browserSessionId),
902
+ eq(schema.browserSessions.networkRouteVersion, input.routeVersion),
903
+ isNull(schema.browserSessions.controllerGeneration),
904
+ ),
905
+ )
906
+ .returning();
907
+ if (!updated) {
908
+ throw new BrowserSessionOperationConflictError(
909
+ "Network route authority binding lost its fence",
910
+ );
911
+ }
912
+ return networkRouteAuthorityFromRow(updated)!;
913
+ }),
914
+ );
915
+ }
916
+
917
+ function normalizedBrowserCapabilities(
918
+ input: PrepareBrowserSessionCreateInput,
919
+ ): BrowserSessionCapabilitiesValue {
920
+ return BrowserSessionCapabilities.parse({
921
+ ...(input.capabilities ?? MANAGED_BROWSER_SESSION_CAPABILITIES),
922
+ linkedComputer: input.linkedComputerSessionId != null,
923
+ });
924
+ }
925
+
926
+ async function assertNetworkRouteAvailable(
927
+ db: Database,
928
+ input: PrepareBrowserSessionCreateInput,
929
+ ): Promise<{
930
+ version: number;
931
+ configuration: NetworkRouteConfigurationValue;
932
+ consistency: NetworkRouteConsistencyValue;
933
+ } | null> {
934
+ const networkRouteId = input.networkRouteId ?? null;
935
+ if (!networkRouteId) return null;
936
+ const [route] = await db
937
+ .select()
938
+ .from(schema.networkRoutes)
939
+ .where(
940
+ and(
941
+ eq(schema.networkRoutes.accountId, input.accountId),
942
+ eq(schema.networkRoutes.workspaceId, input.workspaceId),
943
+ eq(schema.networkRoutes.id, networkRouteId),
944
+ ),
945
+ )
946
+ .limit(1)
947
+ .for("update");
948
+ if (!route) throw new BrowserSessionNotFoundError("Network route not found");
949
+ if (route.status !== "active") {
950
+ throw new BrowserSessionStateError("Network route is archived");
951
+ }
952
+ const configuration = NetworkRouteConfiguration.parse(route.configuration);
953
+ const consistency = NetworkRouteConsistency.parse(route.consistency);
954
+ const compatibilityIssue = networkRoutePlacementCompatibilityIssue(
955
+ configuration,
956
+ consistency,
957
+ input.placement,
958
+ );
959
+ if (compatibilityIssue) throw new BrowserSessionStateError(compatibilityIssue);
960
+ return {
961
+ version: safeInteger(route.version, "network route version"),
962
+ configuration,
963
+ consistency,
964
+ };
965
+ }
966
+
967
+ async function assertLinkedComputerAvailable(
968
+ db: Database,
969
+ input: PrepareBrowserSessionCreateInput,
970
+ ): Promise<void> {
971
+ const computerSessionId = input.linkedComputerSessionId ?? null;
972
+ if (!computerSessionId) return;
973
+ if (input.headless) {
974
+ throw new BrowserSessionStateError("A linked ComputerSession requires a headed browser");
975
+ }
976
+ const [computer] = await db
977
+ .select()
978
+ .from(schema.computerSessions)
979
+ .where(
980
+ and(
981
+ eq(schema.computerSessions.accountId, input.accountId),
982
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
983
+ eq(schema.computerSessions.id, computerSessionId),
984
+ ),
985
+ )
986
+ .limit(1)
987
+ .for("update");
988
+ if (!computer) throw new BrowserSessionNotFoundError("Linked ComputerSession not found");
989
+ if (
990
+ computer.lifecycle !== "active" ||
991
+ computer.controllerGeneration === null ||
992
+ computer.placementInstanceId === null
993
+ ) {
994
+ throw new BrowserSessionStateError("Linked ComputerSession is not active");
995
+ }
996
+ if (!computerMatchesPlacement(computer, input.placement)) {
997
+ throw new BrowserSessionStateError("Linked ComputerSession is on another placement");
998
+ }
999
+ }
1000
+
1001
+ function computerMatchesPlacement(
1002
+ row: typeof schema.computerSessions.$inferSelect,
1003
+ placement: InteractionPlacement,
1004
+ ): boolean {
1005
+ switch (placement.kind) {
1006
+ case "sandbox_group":
1007
+ return (
1008
+ row.placementKind === "sandbox_group" && row.sandboxGroupId === placement.sandboxGroupId
1009
+ );
1010
+ case "connected_machine":
1011
+ return (
1012
+ row.placementKind === "connected_machine" && row.connectedSandboxId === placement.sandboxId
1013
+ );
1014
+ case "attached_device":
1015
+ return row.placementKind === "attached_device" && row.deviceId === placement.deviceId;
1016
+ case "external_provider":
1017
+ return (
1018
+ row.placementKind === "external_provider" &&
1019
+ row.externalProviderId === placement.providerId &&
1020
+ row.externalPlacementId === placement.placementId
1021
+ );
1022
+ }
1023
+ }
1024
+
1025
+ async function resolveCreateIdentitySelection(
1026
+ db: Database,
1027
+ input: PrepareBrowserSessionCreateInput,
1028
+ ): Promise<{ identityId: string | null; baseRevisionId: string | null }> {
1029
+ const resolveDefault = input.resolveDefaultRevision ?? false;
1030
+ if (!input.identityId) {
1031
+ if (input.baseRevisionId || resolveDefault) {
1032
+ throw new BrowserSessionStateError("BrowserSession revision selection requires an identity");
1033
+ }
1034
+ return { identityId: null, baseRevisionId: null };
1035
+ }
1036
+ if (resolveDefault && input.baseRevisionId) {
1037
+ throw new BrowserSessionStateError(
1038
+ "BrowserSession cannot select both a default and exact revision",
1039
+ );
1040
+ }
1041
+ await db.execute(sql`
1042
+ select id from browser_identities
1043
+ where workspace_id = ${input.workspaceId} and id = ${input.identityId}
1044
+ for share
1045
+ `);
1046
+ const [identity] = await db
1047
+ .select({
1048
+ id: schema.browserIdentities.id,
1049
+ status: schema.browserIdentities.status,
1050
+ defaultRevisionId: schema.browserIdentities.defaultRevisionId,
1051
+ })
1052
+ .from(schema.browserIdentities)
1053
+ .where(
1054
+ and(
1055
+ eq(schema.browserIdentities.workspaceId, input.workspaceId),
1056
+ eq(schema.browserIdentities.id, input.identityId),
1057
+ ),
1058
+ )
1059
+ .limit(1);
1060
+ if (!identity) throw new BrowserSessionNotFoundError("BrowserIdentity not found");
1061
+ if (identity.status !== "active") {
1062
+ throw new BrowserSessionStateError("BrowserIdentity is archived");
1063
+ }
1064
+ const baseRevisionId = resolveDefault ? identity.defaultRevisionId : input.baseRevisionId;
1065
+ if (baseRevisionId) {
1066
+ const [revision] = await db
1067
+ .select({ id: schema.browserRevisions.id })
1068
+ .from(schema.browserRevisions)
1069
+ .where(
1070
+ and(
1071
+ eq(schema.browserRevisions.workspaceId, input.workspaceId),
1072
+ eq(schema.browserRevisions.identityId, identity.id),
1073
+ eq(schema.browserRevisions.id, baseRevisionId),
1074
+ ),
1075
+ )
1076
+ .limit(1);
1077
+ if (!revision) {
1078
+ throw new BrowserSessionNotFoundError("BrowserRevision not found");
1079
+ }
1080
+ }
1081
+ return { identityId: identity.id, baseRevisionId: baseRevisionId ?? null };
1082
+ }
1083
+
1084
+ function placementToColumns(placement: InteractionPlacement): {
1085
+ placementKind: BrowserSessionRow["placementKind"];
1086
+ sandboxGroupId: string | null;
1087
+ connectedSandboxId: string | null;
1088
+ deviceId: string | null;
1089
+ externalProviderId: string | null;
1090
+ externalPlacementId: string | null;
1091
+ } {
1092
+ return {
1093
+ placementKind: placement.kind,
1094
+ sandboxGroupId: placement.kind === "sandbox_group" ? placement.sandboxGroupId : null,
1095
+ connectedSandboxId: placement.kind === "connected_machine" ? placement.sandboxId : null,
1096
+ deviceId: placement.kind === "attached_device" ? placement.deviceId : null,
1097
+ externalProviderId: placement.kind === "external_provider" ? placement.providerId : null,
1098
+ externalPlacementId: placement.kind === "external_provider" ? placement.placementId : null,
1099
+ };
1100
+ }
1101
+
1102
+ export async function dispatchBrowserSessionOperation(
1103
+ db: Database,
1104
+ input: {
1105
+ accountId: string;
1106
+ workspaceId: string;
1107
+ operationId: string;
1108
+ browserSessionId: string;
1109
+ controllerGeneration: string;
1110
+ controller?: InteractionControllerBinding;
1111
+ stateUpload?: { objectKey: string; cleanupAfter: Date };
1112
+ },
1113
+ ): Promise<InteractionLifecycleOperationReceiptValue> {
1114
+ const controller = input.controller ? InteractionControllerBinding.parse(input.controller) : null;
1115
+ if (controller && controller.controllerGeneration !== input.controllerGeneration) {
1116
+ throw new BrowserSessionOperationConflictError(
1117
+ "BrowserSession dispatch controller generations disagree",
1118
+ );
1119
+ }
1120
+ return await withRlsContext(
1121
+ db,
1122
+ input,
1123
+ async (scopedDb) =>
1124
+ await scopedDb.transaction(async (txRaw) => {
1125
+ const tx = txRaw as unknown as Database;
1126
+ await lockOperation(tx, input.workspaceId, input.operationId);
1127
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
1128
+ assertOperationResource(operation, input.browserSessionId);
1129
+ if (operation!.state === "prepared") {
1130
+ if (input.stateUpload) {
1131
+ await prepareBrowserStateUploadInTransaction(tx, {
1132
+ accountId: input.accountId,
1133
+ workspaceId: input.workspaceId,
1134
+ operationId: input.operationId,
1135
+ sourceBrowserSessionId: input.browserSessionId,
1136
+ purpose: "private_checkpoint",
1137
+ ...input.stateUpload,
1138
+ });
1139
+ }
1140
+ if (controller) {
1141
+ await lockBrowserSession(tx, input.workspaceId, input.browserSessionId);
1142
+ const now = new Date();
1143
+ const [bound] = await tx
1144
+ .update(schema.browserSessions)
1145
+ .set({
1146
+ controllerId: controller.controllerId,
1147
+ controllerGeneration: controller.controllerGeneration,
1148
+ placementInstanceId: controller.placementInstanceId,
1149
+ controllerHeartbeatAt: now,
1150
+ updatedAt: now,
1151
+ })
1152
+ .where(
1153
+ and(
1154
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1155
+ eq(schema.browserSessions.id, input.browserSessionId),
1156
+ inArray(schema.browserSessions.lifecycle, ["starting", "restoring"]),
1157
+ ),
1158
+ )
1159
+ .returning();
1160
+ if (!bound) {
1161
+ throw new BrowserSessionStateError(
1162
+ "BrowserSession cannot accept a controller dispatch binding",
1163
+ );
1164
+ }
1165
+ }
1166
+ const [updated] = await tx
1167
+ .update(schema.interactionOperations)
1168
+ .set({
1169
+ state: "dispatched",
1170
+ controllerGeneration: input.controllerGeneration,
1171
+ dispatchedAt: new Date(),
1172
+ updatedAt: new Date(),
1173
+ })
1174
+ .where(eq(schema.interactionOperations.operationId, input.operationId))
1175
+ .returning();
1176
+ if (!updated) throw new Error("BrowserSession operation dispatch was lost");
1177
+ return operationReceipt(updated, false);
1178
+ }
1179
+ if (
1180
+ operation!.controllerGeneration !== null &&
1181
+ operation!.controllerGeneration !== input.controllerGeneration
1182
+ ) {
1183
+ throw new BrowserSessionOperationConflictError(
1184
+ "BrowserSession operation belongs to another controller generation",
1185
+ );
1186
+ }
1187
+ if (controller) {
1188
+ const [row] = await tx
1189
+ .select()
1190
+ .from(schema.browserSessions)
1191
+ .where(
1192
+ and(
1193
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1194
+ eq(schema.browserSessions.id, input.browserSessionId),
1195
+ ),
1196
+ )
1197
+ .limit(1);
1198
+ const current = row ? controllerFromRow(row) : null;
1199
+ if (
1200
+ !current ||
1201
+ current.controllerId !== controller.controllerId ||
1202
+ current.controllerGeneration !== controller.controllerGeneration ||
1203
+ current.placementInstanceId !== controller.placementInstanceId
1204
+ ) {
1205
+ throw new BrowserSessionOperationConflictError(
1206
+ "BrowserSession dispatch belongs to another controller binding",
1207
+ );
1208
+ }
1209
+ }
1210
+ if (input.stateUpload && operation!.state === "dispatched") {
1211
+ await prepareBrowserStateUploadInTransaction(tx, {
1212
+ accountId: input.accountId,
1213
+ workspaceId: input.workspaceId,
1214
+ operationId: input.operationId,
1215
+ sourceBrowserSessionId: input.browserSessionId,
1216
+ purpose: "private_checkpoint",
1217
+ ...input.stateUpload,
1218
+ });
1219
+ }
1220
+ return operationReceipt(operation!, true);
1221
+ }),
1222
+ );
1223
+ }
1224
+
1225
+ /** Pulse one exact dispatched lifecycle operation while its controller mutation
1226
+ * is still in flight. This is not viewer or resource activity: it exists only
1227
+ * so the global reaper can distinguish a long profile transfer from an API
1228
+ * caller that disappeared after dispatch. */
1229
+ export async function touchInteractionOperation(
1230
+ db: Database,
1231
+ input: {
1232
+ accountId: string;
1233
+ workspaceId: string;
1234
+ operationId: string;
1235
+ resourceId: string;
1236
+ controllerGeneration: string;
1237
+ },
1238
+ ): Promise<boolean> {
1239
+ return await withRlsContext(db, input, async (scopedDb) => {
1240
+ const [row] = await scopedDb
1241
+ .update(schema.interactionOperations)
1242
+ .set({ updatedAt: new Date() })
1243
+ .where(
1244
+ and(
1245
+ eq(schema.interactionOperations.accountId, input.accountId),
1246
+ eq(schema.interactionOperations.workspaceId, input.workspaceId),
1247
+ eq(schema.interactionOperations.operationId, input.operationId),
1248
+ eq(schema.interactionOperations.resourceId, input.resourceId),
1249
+ eq(schema.interactionOperations.state, "dispatched"),
1250
+ eq(schema.interactionOperations.controllerGeneration, input.controllerGeneration),
1251
+ ),
1252
+ )
1253
+ .returning({ operationId: schema.interactionOperations.operationId });
1254
+ return Boolean(row);
1255
+ });
1256
+ }
1257
+
1258
+ export async function activateBrowserSession(
1259
+ db: Database,
1260
+ input: {
1261
+ accountId: string;
1262
+ workspaceId: string;
1263
+ operationId: string;
1264
+ browserSessionId: string;
1265
+ controller: InteractionControllerBinding;
1266
+ engineVersion: string | null;
1267
+ },
1268
+ ): Promise<BrowserSessionMutationResponseValue> {
1269
+ return await withRlsContext(
1270
+ db,
1271
+ input,
1272
+ async (scopedDb) =>
1273
+ await scopedDb.transaction(async (txRaw) => {
1274
+ const tx = txRaw as unknown as Database;
1275
+ await lockOperation(tx, input.workspaceId, input.operationId);
1276
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
1277
+ assertOperationResource(operation, input.browserSessionId);
1278
+ if (operation!.state === "completed") {
1279
+ return await replayedMutation(tx, input.workspaceId, operation!, {
1280
+ kind: operation!.kind,
1281
+ digest: operation!.requestDigest,
1282
+ });
1283
+ }
1284
+ if (operation!.state !== "prepared" && operation!.state !== "dispatched") {
1285
+ throw new BrowserSessionStateError("BrowserSession operation is already terminal");
1286
+ }
1287
+ if (
1288
+ operation!.controllerGeneration !== null &&
1289
+ operation!.controllerGeneration !== input.controller.controllerGeneration
1290
+ ) {
1291
+ throw new BrowserSessionOperationConflictError(
1292
+ "BrowserSession activation has a stale controller generation",
1293
+ );
1294
+ }
1295
+ await lockBrowserSession(tx, input.workspaceId, input.browserSessionId);
1296
+ const [currentSession] = await tx
1297
+ .select({
1298
+ networkRouteId: schema.browserSessions.networkRouteId,
1299
+ networkRouteAuthorityDigest: schema.browserSessions.networkRouteAuthorityDigest,
1300
+ })
1301
+ .from(schema.browserSessions)
1302
+ .where(
1303
+ and(
1304
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1305
+ eq(schema.browserSessions.id, input.browserSessionId),
1306
+ ),
1307
+ )
1308
+ .limit(1);
1309
+ if (!currentSession) throw new BrowserSessionNotFoundError("BrowserSession not found");
1310
+ if (currentSession.networkRouteId && !currentSession.networkRouteAuthorityDigest) {
1311
+ throw new BrowserSessionStateError(
1312
+ "BrowserSession network route authority was not bound before activation",
1313
+ );
1314
+ }
1315
+ const now = new Date();
1316
+ const [sessionRow] = await tx
1317
+ .update(schema.browserSessions)
1318
+ .set({
1319
+ lifecycle: "active",
1320
+ controllerId: input.controller.controllerId,
1321
+ controllerGeneration: input.controller.controllerGeneration,
1322
+ placementInstanceId: input.controller.placementInstanceId,
1323
+ engineVersion: input.engineVersion,
1324
+ controllerHeartbeatAt: now,
1325
+ lastUsedAt: now,
1326
+ failureCode: null,
1327
+ updatedAt: now,
1328
+ })
1329
+ .where(
1330
+ and(
1331
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1332
+ eq(schema.browserSessions.id, input.browserSessionId),
1333
+ inArray(schema.browserSessions.lifecycle, ["starting", "restoring", "active"]),
1334
+ ),
1335
+ )
1336
+ .returning();
1337
+ if (!sessionRow) throw new BrowserSessionStateError("BrowserSession cannot be activated");
1338
+ const [operationRow] = await tx
1339
+ .update(schema.interactionOperations)
1340
+ .set({
1341
+ state: "completed",
1342
+ controllerGeneration: input.controller.controllerGeneration,
1343
+ dispatchedAt: operation!.dispatchedAt ?? now,
1344
+ settledAt: now,
1345
+ updatedAt: now,
1346
+ })
1347
+ .where(eq(schema.interactionOperations.operationId, input.operationId))
1348
+ .returning();
1349
+ if (!operationRow) throw new Error("BrowserSession operation completion was lost");
1350
+ const associations = await loadAssociations(tx, input.workspaceId, [
1351
+ input.browserSessionId,
1352
+ ]);
1353
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
1354
+ return BrowserSessionMutationResponse.parse({
1355
+ session: browserSessionFromRows(sessionRow, associations),
1356
+ operation: operationReceipt(operationRow, false),
1357
+ });
1358
+ }),
1359
+ );
1360
+ }
1361
+
1362
+ export async function failBrowserSessionOperation(
1363
+ db: Database,
1364
+ input: {
1365
+ accountId: string;
1366
+ workspaceId: string;
1367
+ operationId: string;
1368
+ browserSessionId: string;
1369
+ state?: "failed" | "outcome_unknown";
1370
+ error: InteractionErrorValue;
1371
+ },
1372
+ ): Promise<BrowserSessionMutationResponseValue> {
1373
+ const error = InteractionError.parse(input.error);
1374
+ return await withRlsContext(
1375
+ db,
1376
+ input,
1377
+ async (scopedDb) =>
1378
+ await scopedDb.transaction(async (txRaw) => {
1379
+ const tx = txRaw as unknown as Database;
1380
+ await lockOperation(tx, input.workspaceId, input.operationId);
1381
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
1382
+ assertOperationResource(operation, input.browserSessionId);
1383
+ if (
1384
+ operation!.state === "completed" ||
1385
+ operation!.state === "failed" ||
1386
+ operation!.state === "outcome_unknown"
1387
+ ) {
1388
+ return await replayedMutation(tx, input.workspaceId, operation!, {
1389
+ kind: operation!.kind,
1390
+ digest: operation!.requestDigest,
1391
+ });
1392
+ }
1393
+ await lockBrowserSession(tx, input.workspaceId, input.browserSessionId);
1394
+ const now = new Date();
1395
+ const preserveUncertainBinding = input.state === "outcome_unknown";
1396
+ const [sessionRow] = await tx
1397
+ .update(schema.browserSessions)
1398
+ .set({
1399
+ lifecycle: input.state === "outcome_unknown" ? "lost" : "failed",
1400
+ ...(preserveUncertainBinding
1401
+ ? {}
1402
+ : {
1403
+ controllerId: null,
1404
+ controllerGeneration: null,
1405
+ placementInstanceId: null,
1406
+ }),
1407
+ ...(preserveUncertainBinding ? {} : { controllerHeartbeatAt: null }),
1408
+ failureCode: error.code,
1409
+ updatedAt: now,
1410
+ })
1411
+ .where(
1412
+ and(
1413
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1414
+ eq(schema.browserSessions.id, input.browserSessionId),
1415
+ ),
1416
+ )
1417
+ .returning();
1418
+ if (!sessionRow) throw new BrowserSessionNotFoundError("BrowserSession not found");
1419
+ const [operationRow] = await tx
1420
+ .update(schema.interactionOperations)
1421
+ .set({
1422
+ state: input.state ?? "failed",
1423
+ errorCode: error.code,
1424
+ errorMessage: error.message,
1425
+ errorRetryable: error.retryable,
1426
+ errorDetails: error.details ?? null,
1427
+ settledAt: now,
1428
+ updatedAt: now,
1429
+ })
1430
+ .where(eq(schema.interactionOperations.operationId, input.operationId))
1431
+ .returning();
1432
+ if (!operationRow) throw new Error("BrowserSession failure receipt was lost");
1433
+ const associations = await loadAssociations(tx, input.workspaceId, [
1434
+ input.browserSessionId,
1435
+ ]);
1436
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
1437
+ return BrowserSessionMutationResponse.parse({
1438
+ session: browserSessionFromRows(sessionRow, associations),
1439
+ operation: operationReceipt(operationRow, false),
1440
+ });
1441
+ }),
1442
+ );
1443
+ }
1444
+
1445
+ export async function prepareBrowserSessionSuspend(
1446
+ db: Database,
1447
+ input: PrepareBrowserSessionLifecycleInput,
1448
+ ): Promise<BrowserSessionMutationResponseValue> {
1449
+ return await prepareBrowserSessionLifecycleTransition(db, input, {
1450
+ kind: "suspend",
1451
+ terminalLifecycle: "suspended",
1452
+ targetLifecycle: "suspending",
1453
+ assertReady: (row) => {
1454
+ if (row.lifecycle !== "active" || !controllerFromRow(row)) {
1455
+ throw new BrowserSessionStateError("Only an active BrowserSession can be suspended");
1456
+ }
1457
+ const capabilities = BrowserSessionCapabilities.parse(row.capabilities);
1458
+ if (!capabilities.privateCheckpoint) {
1459
+ throw new BrowserSessionStateError("BrowserSession does not support private checkpoints");
1460
+ }
1461
+ },
1462
+ });
1463
+ }
1464
+
1465
+ export async function prepareBrowserSessionResume(
1466
+ db: Database,
1467
+ input: PrepareBrowserSessionLifecycleInput,
1468
+ ): Promise<BrowserSessionMutationResponseValue> {
1469
+ return await prepareBrowserSessionLifecycleTransition(db, input, {
1470
+ kind: "resume",
1471
+ terminalLifecycle: "active",
1472
+ targetLifecycle: "restoring",
1473
+ assertReady: (row) => {
1474
+ if (row.lifecycle !== "suspended") {
1475
+ throw new BrowserSessionStateError("Only a suspended BrowserSession can be resumed");
1476
+ }
1477
+ if (controllerFromRow(row)) {
1478
+ throw new BrowserSessionStateError(
1479
+ "BrowserSession suspension cleanup must finish before resume",
1480
+ );
1481
+ }
1482
+ if (!row.privateCheckpointArtifactId && !row.baseRevisionId) {
1483
+ throw new BrowserSessionStateError("BrowserSession has no durable state to resume");
1484
+ }
1485
+ },
1486
+ });
1487
+ }
1488
+
1489
+ async function prepareBrowserSessionLifecycleTransition(
1490
+ db: Database,
1491
+ input: PrepareBrowserSessionLifecycleInput,
1492
+ transition: {
1493
+ kind: "suspend" | "resume";
1494
+ terminalLifecycle: "suspended" | "active";
1495
+ targetLifecycle: "suspending" | "restoring";
1496
+ assertReady: (row: BrowserSessionRow) => void;
1497
+ },
1498
+ ): Promise<BrowserSessionMutationResponseValue> {
1499
+ const digest = lifecycleRequestDigest(
1500
+ input.browserSessionId,
1501
+ transition.kind,
1502
+ input.actorSubjectId,
1503
+ );
1504
+ try {
1505
+ return await withRlsContext(
1506
+ db,
1507
+ input,
1508
+ async (scopedDb) =>
1509
+ await scopedDb.transaction(async (txRaw) => {
1510
+ const tx = txRaw as unknown as Database;
1511
+ await lockBrowserSession(tx, input.workspaceId, input.browserSessionId);
1512
+ const [row] = await tx
1513
+ .select()
1514
+ .from(schema.browserSessions)
1515
+ .where(
1516
+ and(
1517
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1518
+ eq(schema.browserSessions.id, input.browserSessionId),
1519
+ ),
1520
+ )
1521
+ .limit(1);
1522
+ if (!row) throw new BrowserSessionNotFoundError("BrowserSession not found");
1523
+
1524
+ const existing = await loadOperation(tx, input.workspaceId, input.operationId);
1525
+ if (existing) {
1526
+ return await replayedMutation(tx, input.workspaceId, existing, {
1527
+ kind: transition.kind,
1528
+ digest,
1529
+ });
1530
+ }
1531
+
1532
+ const terminal = row.lifecycle === transition.terminalLifecycle;
1533
+ if (!terminal) transition.assertReady(row);
1534
+ const now = new Date();
1535
+ const [operation] = await tx
1536
+ .insert(schema.interactionOperations)
1537
+ .values({
1538
+ operationId: input.operationId,
1539
+ accountId: input.accountId,
1540
+ workspaceId: input.workspaceId,
1541
+ resourceKind: "browser_session",
1542
+ resourceId: input.browserSessionId,
1543
+ kind: transition.kind,
1544
+ requestDigest: digest,
1545
+ state: terminal ? "completed" : "prepared",
1546
+ actorSubjectId: input.actorSubjectId,
1547
+ ...(terminal ? { dispatchedAt: now, settledAt: now } : {}),
1548
+ })
1549
+ .returning();
1550
+ if (!operation) throw new Error("BrowserSession lifecycle operation insert was lost");
1551
+
1552
+ const sessionRow = terminal
1553
+ ? row
1554
+ : (
1555
+ await tx
1556
+ .update(schema.browserSessions)
1557
+ .set({
1558
+ lifecycle: transition.targetLifecycle,
1559
+ failureCode: null,
1560
+ ...(transition.kind === "resume"
1561
+ ? {
1562
+ networkRouteCredentialVersion: null,
1563
+ networkRouteAuthorityDigest: null,
1564
+ }
1565
+ : {}),
1566
+ updatedAt: now,
1567
+ })
1568
+ .where(
1569
+ and(
1570
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1571
+ eq(schema.browserSessions.id, input.browserSessionId),
1572
+ ),
1573
+ )
1574
+ .returning()
1575
+ )[0];
1576
+ if (!sessionRow) throw new Error("BrowserSession lifecycle transition was lost");
1577
+ const associations = await loadAssociations(tx, input.workspaceId, [
1578
+ input.browserSessionId,
1579
+ ]);
1580
+ if (!terminal) {
1581
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
1582
+ }
1583
+ return BrowserSessionMutationResponse.parse({
1584
+ session: browserSessionFromRows(sessionRow, associations),
1585
+ operation: operationReceipt(operation, false),
1586
+ });
1587
+ }),
1588
+ );
1589
+ } catch (error) {
1590
+ if (postgresConstraint(error) === "interaction_operations_active_resource_uq") {
1591
+ throw new BrowserSessionOperationConflictError(
1592
+ "BrowserSession already has an active lifecycle operation",
1593
+ );
1594
+ }
1595
+ throw error;
1596
+ }
1597
+ }
1598
+
1599
+ export async function commitBrowserSessionSuspension(
1600
+ db: Database,
1601
+ input: {
1602
+ accountId: string;
1603
+ workspaceId: string;
1604
+ operationId: string;
1605
+ browserSessionId: string;
1606
+ controllerGeneration: string;
1607
+ artifact: BrowserStateArtifactCommitInput;
1608
+ },
1609
+ ): Promise<BrowserSessionMutationResponseValue> {
1610
+ let artifact: BrowserStateArtifactCommitInput;
1611
+ try {
1612
+ artifact = validateBrowserStateArtifactCommitInput(input.workspaceId, input.artifact);
1613
+ } catch (error) {
1614
+ throw new BrowserSessionStateError(
1615
+ error instanceof Error ? error.message : "Browser checkpoint metadata is invalid",
1616
+ );
1617
+ }
1618
+ return await withRlsContext(
1619
+ db,
1620
+ input,
1621
+ async (scopedDb) =>
1622
+ await scopedDb.transaction(async (txRaw) => {
1623
+ const tx = txRaw as unknown as Database;
1624
+ await lockOperation(tx, input.workspaceId, input.operationId);
1625
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
1626
+ assertOperationResource(operation, input.browserSessionId, "suspend");
1627
+ if (operation!.state === "completed") {
1628
+ return await replayedMutation(tx, input.workspaceId, operation!, {
1629
+ kind: "suspend",
1630
+ digest: operation!.requestDigest,
1631
+ });
1632
+ }
1633
+ if (operation!.state !== "dispatched") {
1634
+ throw new BrowserSessionStateError("BrowserSession suspension is not dispatched");
1635
+ }
1636
+ if (operation!.controllerGeneration !== input.controllerGeneration) {
1637
+ throw new BrowserSessionOperationConflictError(
1638
+ "BrowserSession suspension controller generation changed",
1639
+ );
1640
+ }
1641
+
1642
+ await lockBrowserSession(tx, input.workspaceId, input.browserSessionId);
1643
+ const [current] = await tx
1644
+ .select()
1645
+ .from(schema.browserSessions)
1646
+ .where(
1647
+ and(
1648
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1649
+ eq(schema.browserSessions.id, input.browserSessionId),
1650
+ ),
1651
+ )
1652
+ .limit(1);
1653
+ if (!current) throw new BrowserSessionNotFoundError("BrowserSession not found");
1654
+ if (
1655
+ current.lifecycle !== "suspending" ||
1656
+ current.controllerGeneration !== input.controllerGeneration
1657
+ ) {
1658
+ throw new BrowserSessionOperationConflictError(
1659
+ "BrowserSession suspension controller authority changed",
1660
+ );
1661
+ }
1662
+
1663
+ const now = new Date();
1664
+ const artifactId = randomUUID();
1665
+ const [artifactRow] = await tx
1666
+ .insert(schema.browserStateArtifacts)
1667
+ .values({
1668
+ id: artifactId,
1669
+ accountId: input.accountId,
1670
+ workspaceId: input.workspaceId,
1671
+ sourceBrowserSessionId: input.browserSessionId,
1672
+ purpose: "private_checkpoint",
1673
+ ...artifact,
1674
+ })
1675
+ .returning({ id: schema.browserStateArtifacts.id });
1676
+ if (!artifactRow) throw new Error("Browser checkpoint artifact insert was lost");
1677
+ await commitBrowserStateUploadInTransaction(tx, {
1678
+ workspaceId: input.workspaceId,
1679
+ operationId: input.operationId,
1680
+ sourceBrowserSessionId: input.browserSessionId,
1681
+ purpose: "private_checkpoint",
1682
+ objectKey: artifact.objectKey,
1683
+ artifactId,
1684
+ });
1685
+
1686
+ if (current.privateCheckpointArtifactId) {
1687
+ const [retired] = await tx
1688
+ .update(schema.browserStateArtifacts)
1689
+ .set({ state: "delete_pending", retainedUntil: now })
1690
+ .where(
1691
+ and(
1692
+ eq(schema.browserStateArtifacts.workspaceId, input.workspaceId),
1693
+ eq(schema.browserStateArtifacts.id, current.privateCheckpointArtifactId),
1694
+ eq(schema.browserStateArtifacts.sourceBrowserSessionId, input.browserSessionId),
1695
+ eq(schema.browserStateArtifacts.purpose, "private_checkpoint"),
1696
+ eq(schema.browserStateArtifacts.state, "available"),
1697
+ ),
1698
+ )
1699
+ .returning({ id: schema.browserStateArtifacts.id });
1700
+ if (!retired) {
1701
+ throw new BrowserSessionStateError(
1702
+ "BrowserSession previous private checkpoint authority is inconsistent",
1703
+ );
1704
+ }
1705
+ }
1706
+
1707
+ const [sessionRow] = await tx
1708
+ .update(schema.browserSessions)
1709
+ .set({
1710
+ lifecycle: "suspended",
1711
+ privateCheckpointArtifactId: artifactId,
1712
+ failureCode: null,
1713
+ updatedAt: now,
1714
+ })
1715
+ .where(
1716
+ and(
1717
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1718
+ eq(schema.browserSessions.id, input.browserSessionId),
1719
+ eq(schema.browserSessions.lifecycle, "suspending"),
1720
+ eq(schema.browserSessions.controllerGeneration, input.controllerGeneration),
1721
+ ),
1722
+ )
1723
+ .returning();
1724
+ const [operationRow] = await tx
1725
+ .update(schema.interactionOperations)
1726
+ .set({ state: "completed", settledAt: now, updatedAt: now })
1727
+ .where(
1728
+ and(
1729
+ eq(schema.interactionOperations.workspaceId, input.workspaceId),
1730
+ eq(schema.interactionOperations.operationId, input.operationId),
1731
+ eq(schema.interactionOperations.state, "dispatched"),
1732
+ ),
1733
+ )
1734
+ .returning();
1735
+ if (!sessionRow || !operationRow) {
1736
+ throw new Error("BrowserSession suspension completion was lost");
1737
+ }
1738
+ const associations = await loadAssociations(tx, input.workspaceId, [
1739
+ input.browserSessionId,
1740
+ ]);
1741
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
1742
+ return BrowserSessionMutationResponse.parse({
1743
+ session: browserSessionFromRows(sessionRow, associations),
1744
+ operation: operationReceipt(operationRow, false),
1745
+ });
1746
+ }),
1747
+ );
1748
+ }
1749
+
1750
+ export async function clearSuspendedBrowserSessionController(
1751
+ db: Database,
1752
+ input: {
1753
+ accountId: string;
1754
+ workspaceId: string;
1755
+ browserSessionId: string;
1756
+ expectedControllerGeneration: string;
1757
+ },
1758
+ ): Promise<boolean> {
1759
+ return await withRlsContext(db, input, async (scopedDb) =>
1760
+ scopedDb.transaction(async (txRaw) => {
1761
+ const tx = txRaw as unknown as Database;
1762
+ await lockBrowserSession(tx, input.workspaceId, input.browserSessionId);
1763
+ const [current] = await tx
1764
+ .select()
1765
+ .from(schema.browserSessions)
1766
+ .where(
1767
+ and(
1768
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1769
+ eq(schema.browserSessions.id, input.browserSessionId),
1770
+ ),
1771
+ )
1772
+ .limit(1);
1773
+ if (!current) throw new BrowserSessionNotFoundError("BrowserSession not found");
1774
+ if (current.lifecycle !== "suspended") {
1775
+ throw new BrowserSessionStateError("BrowserSession is not suspended");
1776
+ }
1777
+ if (!current.controllerGeneration) return false;
1778
+ if (current.controllerGeneration !== input.expectedControllerGeneration) {
1779
+ throw new BrowserSessionOperationConflictError(
1780
+ "BrowserSession suspension cleanup has a stale controller generation",
1781
+ );
1782
+ }
1783
+ const [cleared] = await tx
1784
+ .update(schema.browserSessions)
1785
+ .set({
1786
+ controllerId: null,
1787
+ controllerGeneration: null,
1788
+ placementInstanceId: null,
1789
+ controllerHeartbeatAt: null,
1790
+ updatedAt: new Date(),
1791
+ })
1792
+ .where(
1793
+ and(
1794
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1795
+ eq(schema.browserSessions.id, input.browserSessionId),
1796
+ eq(schema.browserSessions.lifecycle, "suspended"),
1797
+ eq(schema.browserSessions.controllerGeneration, input.expectedControllerGeneration),
1798
+ ),
1799
+ )
1800
+ .returning({ id: schema.browserSessions.id });
1801
+ if (!cleared) throw new Error("BrowserSession suspension cleanup was lost");
1802
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
1803
+ return true;
1804
+ }),
1805
+ );
1806
+ }
1807
+
1808
+ export async function failBrowserSessionSuspension(
1809
+ db: Database,
1810
+ input: {
1811
+ accountId: string;
1812
+ workspaceId: string;
1813
+ operationId: string;
1814
+ browserSessionId: string;
1815
+ controllerGeneration: string;
1816
+ state?: "failed" | "outcome_unknown";
1817
+ error: InteractionErrorValue;
1818
+ },
1819
+ ): Promise<BrowserSessionMutationResponseValue> {
1820
+ return await failBrowserSessionTransition(db, {
1821
+ ...input,
1822
+ kind: "suspend",
1823
+ expectedLifecycle: "suspending",
1824
+ resultLifecycle: "active",
1825
+ clearController: false,
1826
+ });
1827
+ }
1828
+
1829
+ export async function failBrowserSessionResume(
1830
+ db: Database,
1831
+ input: {
1832
+ accountId: string;
1833
+ workspaceId: string;
1834
+ operationId: string;
1835
+ browserSessionId: string;
1836
+ controllerGeneration: string;
1837
+ error: InteractionErrorValue;
1838
+ },
1839
+ ): Promise<BrowserSessionMutationResponseValue> {
1840
+ return await failBrowserSessionTransition(db, {
1841
+ ...input,
1842
+ kind: "resume",
1843
+ expectedLifecycle: "restoring",
1844
+ resultLifecycle: "suspended",
1845
+ state: "failed",
1846
+ clearController: true,
1847
+ });
1848
+ }
1849
+
1850
+ export async function failBrowserSessionResumePreparation(
1851
+ db: Database,
1852
+ input: {
1853
+ accountId: string;
1854
+ workspaceId: string;
1855
+ operationId: string;
1856
+ browserSessionId: string;
1857
+ error: InteractionErrorValue;
1858
+ },
1859
+ ): Promise<BrowserSessionMutationResponseValue> {
1860
+ return await failBrowserSessionTransition(db, {
1861
+ ...input,
1862
+ controllerGeneration: null,
1863
+ kind: "resume",
1864
+ expectedLifecycle: "restoring",
1865
+ resultLifecycle: "suspended",
1866
+ state: "failed",
1867
+ clearController: true,
1868
+ });
1869
+ }
1870
+
1871
+ async function failBrowserSessionTransition(
1872
+ db: Database,
1873
+ input: {
1874
+ accountId: string;
1875
+ workspaceId: string;
1876
+ operationId: string;
1877
+ browserSessionId: string;
1878
+ controllerGeneration: string | null;
1879
+ kind: "suspend" | "resume";
1880
+ expectedLifecycle: "suspending" | "restoring";
1881
+ resultLifecycle: "active" | "suspended";
1882
+ state?: "failed" | "outcome_unknown";
1883
+ clearController: boolean;
1884
+ error: InteractionErrorValue;
1885
+ },
1886
+ ): Promise<BrowserSessionMutationResponseValue> {
1887
+ const failure = InteractionError.parse(input.error);
1888
+ return await withRlsContext(
1889
+ db,
1890
+ input,
1891
+ async (scopedDb) =>
1892
+ await scopedDb.transaction(async (txRaw) => {
1893
+ const tx = txRaw as unknown as Database;
1894
+ await lockOperation(tx, input.workspaceId, input.operationId);
1895
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
1896
+ assertOperationResource(operation, input.browserSessionId, input.kind);
1897
+ if (
1898
+ operation!.state === "completed" ||
1899
+ operation!.state === "failed" ||
1900
+ operation!.state === "outcome_unknown"
1901
+ ) {
1902
+ return await replayedMutation(tx, input.workspaceId, operation!, {
1903
+ kind: input.kind,
1904
+ digest: operation!.requestDigest,
1905
+ });
1906
+ }
1907
+ if (operation!.controllerGeneration !== input.controllerGeneration) {
1908
+ throw new BrowserSessionOperationConflictError(
1909
+ "BrowserSession lifecycle failure has a stale controller generation",
1910
+ );
1911
+ }
1912
+ await lockBrowserSession(tx, input.workspaceId, input.browserSessionId);
1913
+ const now = new Date();
1914
+ const [sessionRow] = await tx
1915
+ .update(schema.browserSessions)
1916
+ .set({
1917
+ lifecycle: input.resultLifecycle,
1918
+ ...(input.clearController
1919
+ ? {
1920
+ controllerId: null,
1921
+ controllerGeneration: null,
1922
+ placementInstanceId: null,
1923
+ controllerHeartbeatAt: null,
1924
+ }
1925
+ : {}),
1926
+ failureCode: null,
1927
+ updatedAt: now,
1928
+ })
1929
+ .where(
1930
+ and(
1931
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
1932
+ eq(schema.browserSessions.id, input.browserSessionId),
1933
+ eq(schema.browserSessions.lifecycle, input.expectedLifecycle),
1934
+ input.controllerGeneration === null
1935
+ ? isNull(schema.browserSessions.controllerGeneration)
1936
+ : eq(schema.browserSessions.controllerGeneration, input.controllerGeneration),
1937
+ ),
1938
+ )
1939
+ .returning();
1940
+ const [operationRow] = await tx
1941
+ .update(schema.interactionOperations)
1942
+ .set({
1943
+ state: input.state ?? "failed",
1944
+ errorCode: failure.code,
1945
+ errorMessage: failure.message,
1946
+ errorRetryable: failure.retryable,
1947
+ errorDetails: failure.details ?? null,
1948
+ settledAt: now,
1949
+ updatedAt: now,
1950
+ })
1951
+ .where(eq(schema.interactionOperations.operationId, input.operationId))
1952
+ .returning();
1953
+ if (!sessionRow || !operationRow) {
1954
+ throw new Error("BrowserSession lifecycle failure settlement was lost");
1955
+ }
1956
+ if (input.kind === "suspend") {
1957
+ await markBrowserStateUploadsDeletePendingInTransaction(tx, input);
1958
+ }
1959
+ const associations = await loadAssociations(tx, input.workspaceId, [
1960
+ input.browserSessionId,
1961
+ ]);
1962
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
1963
+ return BrowserSessionMutationResponse.parse({
1964
+ session: browserSessionFromRows(sessionRow, associations),
1965
+ operation: operationReceipt(operationRow, false),
1966
+ });
1967
+ }),
1968
+ );
1969
+ }
1970
+
1971
+ export async function prepareBrowserSessionEnd(
1972
+ db: Database,
1973
+ input: PrepareBrowserSessionEndInput,
1974
+ ): Promise<BrowserSessionMutationResponseValue> {
1975
+ const digest = lifecycleRequestDigest(input.browserSessionId, "end", input.actorSubjectId);
1976
+ try {
1977
+ return await withRlsContext(
1978
+ db,
1979
+ input,
1980
+ async (scopedDb) =>
1981
+ await scopedDb.transaction(async (txRaw) => {
1982
+ const tx = txRaw as unknown as Database;
1983
+ await lockBrowserSession(tx, input.workspaceId, input.browserSessionId);
1984
+ const session = await loadBrowserSession(tx, input.workspaceId, input.browserSessionId);
1985
+ if (!session) throw new BrowserSessionNotFoundError("BrowserSession not found");
1986
+ const now = new Date();
1987
+ const terminal = session.lifecycle === "ended";
1988
+ const [insertedOperation] = await tx
1989
+ .insert(schema.interactionOperations)
1990
+ .values({
1991
+ operationId: input.operationId,
1992
+ accountId: input.accountId,
1993
+ workspaceId: input.workspaceId,
1994
+ resourceKind: "browser_session",
1995
+ resourceId: input.browserSessionId,
1996
+ kind: "end",
1997
+ requestDigest: digest,
1998
+ state: terminal ? "completed" : "prepared",
1999
+ actorSubjectId: input.actorSubjectId,
2000
+ ...(terminal ? { dispatchedAt: now, settledAt: now } : {}),
2001
+ })
2002
+ .onConflictDoNothing({
2003
+ target: schema.interactionOperations.operationId,
2004
+ })
2005
+ .returning();
2006
+ if (!insertedOperation) {
2007
+ const existing = await loadOperation(tx, input.workspaceId, input.operationId);
2008
+ if (!existing) {
2009
+ throw new BrowserSessionOperationConflictError(
2010
+ "Interaction operation id belongs to another workspace",
2011
+ );
2012
+ }
2013
+ return await replayedMutation(tx, input.workspaceId, existing, {
2014
+ kind: "end",
2015
+ digest,
2016
+ });
2017
+ }
2018
+ if (terminal) {
2019
+ return BrowserSessionMutationResponse.parse({
2020
+ session,
2021
+ operation: operationReceipt(insertedOperation, false),
2022
+ });
2023
+ }
2024
+ const [sessionRow] = await tx
2025
+ .update(schema.browserSessions)
2026
+ .set({ lifecycle: "ending", failureCode: null, updatedAt: now })
2027
+ .where(
2028
+ and(
2029
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
2030
+ eq(schema.browserSessions.id, input.browserSessionId),
2031
+ ),
2032
+ )
2033
+ .returning();
2034
+ if (!sessionRow) throw new BrowserSessionNotFoundError("BrowserSession not found");
2035
+ const associations = await loadAssociations(tx, input.workspaceId, [
2036
+ input.browserSessionId,
2037
+ ]);
2038
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
2039
+ return BrowserSessionMutationResponse.parse({
2040
+ session: browserSessionFromRows(sessionRow, associations),
2041
+ operation: operationReceipt(insertedOperation, false),
2042
+ });
2043
+ }),
2044
+ );
2045
+ } catch (error) {
2046
+ if (postgresConstraint(error) === "interaction_operations_active_resource_uq") {
2047
+ throw new BrowserSessionOperationConflictError(
2048
+ "BrowserSession already has an active lifecycle operation",
2049
+ );
2050
+ }
2051
+ throw error;
2052
+ }
2053
+ }
2054
+
2055
+ export async function completeBrowserSessionEnd(
2056
+ db: Database,
2057
+ input: {
2058
+ accountId: string;
2059
+ workspaceId: string;
2060
+ operationId: string;
2061
+ browserSessionId: string;
2062
+ expectedControllerGeneration: string | null;
2063
+ },
2064
+ ): Promise<BrowserSessionMutationResponseValue> {
2065
+ return await withRlsContext(
2066
+ db,
2067
+ input,
2068
+ async (scopedDb) =>
2069
+ await scopedDb.transaction(async (txRaw) => {
2070
+ const tx = txRaw as unknown as Database;
2071
+ await lockOperation(tx, input.workspaceId, input.operationId);
2072
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
2073
+ assertOperationResource(operation, input.browserSessionId, "end");
2074
+ if (operation!.state === "completed") {
2075
+ return await replayedMutation(tx, input.workspaceId, operation!, {
2076
+ kind: "end",
2077
+ digest: operation!.requestDigest,
2078
+ });
2079
+ }
2080
+ if (operation!.state !== "prepared" && operation!.state !== "dispatched") {
2081
+ throw new BrowserSessionStateError("BrowserSession end operation is already terminal");
2082
+ }
2083
+ await lockBrowserSession(tx, input.workspaceId, input.browserSessionId);
2084
+ const [current] = await tx
2085
+ .select()
2086
+ .from(schema.browserSessions)
2087
+ .where(
2088
+ and(
2089
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
2090
+ eq(schema.browserSessions.id, input.browserSessionId),
2091
+ ),
2092
+ )
2093
+ .limit(1);
2094
+ if (!current) throw new BrowserSessionNotFoundError("BrowserSession not found");
2095
+ if (current.controllerGeneration !== input.expectedControllerGeneration) {
2096
+ throw new BrowserSessionOperationConflictError(
2097
+ "BrowserSession end has a stale controller generation",
2098
+ );
2099
+ }
2100
+ const now = new Date();
2101
+ if (current.privateCheckpointArtifactId) {
2102
+ const [retired] = await tx
2103
+ .update(schema.browserStateArtifacts)
2104
+ .set({ state: "delete_pending", retainedUntil: now })
2105
+ .where(
2106
+ and(
2107
+ eq(schema.browserStateArtifacts.workspaceId, input.workspaceId),
2108
+ eq(schema.browserStateArtifacts.id, current.privateCheckpointArtifactId),
2109
+ eq(schema.browserStateArtifacts.sourceBrowserSessionId, input.browserSessionId),
2110
+ eq(schema.browserStateArtifacts.purpose, "private_checkpoint"),
2111
+ eq(schema.browserStateArtifacts.state, "available"),
2112
+ ),
2113
+ )
2114
+ .returning({ id: schema.browserStateArtifacts.id });
2115
+ if (!retired) {
2116
+ throw new BrowserSessionStateError(
2117
+ "BrowserSession private checkpoint authority is inconsistent",
2118
+ );
2119
+ }
2120
+ }
2121
+ const [sessionRow] = await tx
2122
+ .update(schema.browserSessions)
2123
+ .set({
2124
+ lifecycle: "ended",
2125
+ controllerId: null,
2126
+ controllerGeneration: null,
2127
+ placementInstanceId: null,
2128
+ controllerHeartbeatAt: null,
2129
+ privateCheckpointArtifactId: null,
2130
+ failureCode: null,
2131
+ updatedAt: now,
2132
+ })
2133
+ .where(
2134
+ and(
2135
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
2136
+ eq(schema.browserSessions.id, input.browserSessionId),
2137
+ ),
2138
+ )
2139
+ .returning();
2140
+ const [operationRow] = await tx
2141
+ .update(schema.interactionOperations)
2142
+ .set({
2143
+ state: "completed",
2144
+ controllerGeneration: input.expectedControllerGeneration,
2145
+ dispatchedAt: operation!.dispatchedAt ?? now,
2146
+ settledAt: now,
2147
+ updatedAt: now,
2148
+ })
2149
+ .where(
2150
+ and(
2151
+ eq(schema.interactionOperations.workspaceId, input.workspaceId),
2152
+ eq(schema.interactionOperations.operationId, input.operationId),
2153
+ ),
2154
+ )
2155
+ .returning();
2156
+ if (!sessionRow || !operationRow) throw new Error("BrowserSession end completion was lost");
2157
+ const associations = await loadAssociations(tx, input.workspaceId, [
2158
+ input.browserSessionId,
2159
+ ]);
2160
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
2161
+ return BrowserSessionMutationResponse.parse({
2162
+ session: browserSessionFromRows(sessionRow, associations),
2163
+ operation: operationReceipt(operationRow, false),
2164
+ });
2165
+ }),
2166
+ );
2167
+ }
2168
+
2169
+ export async function touchBrowserSessionController(
2170
+ db: Database,
2171
+ input: {
2172
+ accountId: string;
2173
+ workspaceId: string;
2174
+ browserSessionId: string;
2175
+ controllerGeneration: string;
2176
+ },
2177
+ ): Promise<boolean> {
2178
+ return await withRlsContext(
2179
+ db,
2180
+ input,
2181
+ async (scopedDb) =>
2182
+ await scopedDb.transaction(async (txRaw) => {
2183
+ const tx = txRaw as unknown as Database;
2184
+ const [observed] = await tx
2185
+ .select()
2186
+ .from(schema.browserSessions)
2187
+ .where(
2188
+ and(
2189
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
2190
+ eq(schema.browserSessions.id, input.browserSessionId),
2191
+ eq(schema.browserSessions.lifecycle, "active"),
2192
+ eq(schema.browserSessions.controllerGeneration, input.controllerGeneration),
2193
+ ),
2194
+ )
2195
+ .limit(1);
2196
+ if (!observed) return false;
2197
+ if (observed.controllerHostSandboxGroupId) {
2198
+ // Match the reaper's exact lease -> holder -> BrowserSession lock
2199
+ // order. The pre-lock observation is only a locator; every authority
2200
+ // predicate is repeated below while the corresponding row is locked.
2201
+ const lease = await tx.execute<{ id: string }>(sql`
2202
+ select lease.id
2203
+ from sandbox_leases lease
2204
+ join browser_sessions browser
2205
+ on browser.account_id = lease.account_id
2206
+ and browser.workspace_id = lease.workspace_id
2207
+ and browser.controller_host_sandbox_group_id = lease.sandbox_group_id
2208
+ where browser.account_id = ${input.accountId}
2209
+ and browser.workspace_id = ${input.workspaceId}
2210
+ and browser.id = ${input.browserSessionId}
2211
+ and browser.lifecycle = 'active'
2212
+ and browser.controller_generation = ${input.controllerGeneration}
2213
+ and browser.placement_instance_id = lease.instance_id
2214
+ for update of lease
2215
+ `);
2216
+ if (!lease[0]) return false;
2217
+ const holder = await tx.execute<{ id: string }>(sql`
2218
+ update sandbox_lease_holders holder set last_heartbeat_at = now()
2219
+ from sandbox_leases lease
2220
+ where lease.id = holder.lease_id
2221
+ and holder.account_id = ${input.accountId}
2222
+ and holder.workspace_id = ${input.workspaceId}
2223
+ and holder.kind = 'interaction'
2224
+ and holder.holder_id = ${`browser-session:${input.browserSessionId}`}
2225
+ and lease.workspace_id = ${input.workspaceId}
2226
+ and lease.sandbox_group_id = ${observed.controllerHostSandboxGroupId}
2227
+ and lease.instance_id = ${observed.placementInstanceId}
2228
+ returning holder.id
2229
+ `);
2230
+ if (!holder[0]) return false;
2231
+ }
2232
+ await lockBrowserSession(tx, input.workspaceId, input.browserSessionId);
2233
+ const now = new Date();
2234
+ const [updated] = await tx
2235
+ .update(schema.browserSessions)
2236
+ .set({ controllerHeartbeatAt: now, lastUsedAt: now, updatedAt: now })
2237
+ .where(
2238
+ and(
2239
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
2240
+ eq(schema.browserSessions.id, input.browserSessionId),
2241
+ eq(schema.browserSessions.lifecycle, "active"),
2242
+ eq(schema.browserSessions.controllerGeneration, input.controllerGeneration),
2243
+ ),
2244
+ )
2245
+ .returning({ id: schema.browserSessions.id });
2246
+ return updated !== undefined;
2247
+ }),
2248
+ );
2249
+ }
2250
+
2251
+ async function lockOperation(
2252
+ db: Database,
2253
+ workspaceId: string,
2254
+ operationId: string,
2255
+ ): Promise<void> {
2256
+ await db.execute(sql`
2257
+ select operation_id from interaction_operations
2258
+ where workspace_id = ${workspaceId} and operation_id = ${operationId}
2259
+ for update
2260
+ `);
2261
+ }
2262
+
2263
+ async function lockBrowserSession(
2264
+ db: Database,
2265
+ workspaceId: string,
2266
+ browserSessionId: string,
2267
+ ): Promise<void> {
2268
+ await db.execute(sql`
2269
+ select id from browser_sessions
2270
+ where workspace_id = ${workspaceId} and id = ${browserSessionId}
2271
+ for update
2272
+ `);
2273
+ }
2274
+
2275
+ function assertOperationResource(
2276
+ operation: InteractionOperationRow | null,
2277
+ browserSessionId: string,
2278
+ kind?: InteractionLifecycleOperationKind,
2279
+ ): asserts operation is InteractionOperationRow {
2280
+ if (!operation) throw new BrowserSessionNotFoundError("BrowserSession operation not found");
2281
+ if (
2282
+ operation.resourceKind !== "browser_session" ||
2283
+ operation.resourceId !== browserSessionId ||
2284
+ (kind !== undefined && operation.kind !== kind)
2285
+ ) {
2286
+ throw new BrowserSessionOperationConflictError(
2287
+ "Interaction operation is bound to another resource",
2288
+ );
2289
+ }
2290
+ }
2291
+
2292
+ function postgresConstraint(error: unknown): string | null {
2293
+ return safeDatabaseErrorFacts(error).constraint ?? null;
2294
+ }