@opengeni/db 0.28.9 → 0.31.1

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