@opengeni/db 0.28.1 → 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 (187) 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-DBS5HPEW.js → chunk-YHZXQ7HP.js} +760 -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/environment-crypto.d.ts +8 -4
  35. package/dist/generated-images.d.ts +139 -0
  36. package/dist/index.d.ts +958 -126
  37. package/dist/index.js +31204 -12172
  38. package/dist/index.js.map +1 -1
  39. package/dist/insights.d.ts +51 -4
  40. package/dist/integration-bindings.d.ts +70 -0
  41. package/dist/integration-features.d.ts +99 -0
  42. package/dist/interaction-revisions.d.ts +3 -0
  43. package/dist/interaction-schema.d.ts +5372 -0
  44. package/dist/knowledge-source-sync-schema.d.ts +1679 -0
  45. package/dist/knowledge-source-sync.d.ts +274 -0
  46. package/dist/lossless-columns.d.ts +58 -0
  47. package/dist/lossless-json.d.ts +39 -0
  48. package/dist/memory-domain.d.ts +3 -6
  49. package/dist/memory-governance.d.ts +2 -2
  50. package/dist/memory-slack-delivery.d.ts +101 -0
  51. package/dist/new-session-drafts.d.ts +2 -2
  52. package/dist/pack-components.d.ts +76 -0
  53. package/dist/pack-installations.d.ts +86 -0
  54. package/dist/persistence-errors.d.ts +7 -14
  55. package/dist/plugin-packages.d.ts +126 -0
  56. package/dist/preference-registry.d.ts +1 -1
  57. package/dist/provision-roles.d.ts +9 -1
  58. package/dist/provision-roles.js +1 -1
  59. package/dist/runtime-posture.d.ts +18 -6
  60. package/dist/schema.d.ts +19122 -8731
  61. package/dist/schema.js +173 -1
  62. package/dist/scoped-knowledge.d.ts +97 -1
  63. package/dist/session-control.d.ts +4 -4
  64. package/dist/session-queue-commands.d.ts +19 -11
  65. package/dist/session-realtime-context.d.ts +2 -2
  66. package/dist/session-realtime-ledger.d.ts +4 -4
  67. package/dist/session-realtime-mirror.d.ts +2 -2
  68. package/dist/session-realtime-state.d.ts +1 -1
  69. package/dist/session-realtime-terminal.d.ts +8 -1
  70. package/dist/session-realtime.d.ts +7 -7
  71. package/dist/session-tool-call-settlement.d.ts +1 -1
  72. package/dist/slack-user-link-access.d.ts +54 -0
  73. package/dist/transcription-recordings-schema.d.ts +44 -6
  74. package/dist/transcription-recordings.d.ts +3 -3
  75. package/dist/turn-initiator.d.ts +2 -2
  76. package/dist/video-generation.d.ts +216 -0
  77. package/dist/video-generation.js +66 -0
  78. package/dist/video-generation.js.map +1 -0
  79. package/dist/workspace-artifacts.d.ts +1 -1
  80. package/dist/workspace-instruction-policies.d.ts +1 -1
  81. package/dist/workspace-learning-policy-schema.d.ts +932 -0
  82. package/dist/workspace-learning-policy.d.ts +161 -0
  83. package/drizzle/0065_enrollment_credential_generation.sql +10 -0
  84. package/drizzle/0140_retained_screenshot_artifacts.sql +192 -0
  85. package/drizzle/0176_lossless_canonical_json.sql +975 -0
  86. package/drizzle/0177_session_events_workspace_turn_type_index.sql +5 -0
  87. package/drizzle/0178_permissioned_secret_reads.sql +14 -0
  88. package/drizzle/0179_slack_private_shortcut_delivery_gate.sql +85 -0
  89. package/drizzle/0180_retained_screenshot_lifecycle_fences.sql +273 -0
  90. package/drizzle/0181_connected_machine_removal.sql +52 -0
  91. package/drizzle/0182_connected_machine_remove_session_default.sql +77 -0
  92. package/drizzle/0183_model_call_provider_cost_estimates.sql +39 -0
  93. package/drizzle/0184_sandbox_drain_teardown_fence.sql +682 -0
  94. package/drizzle/0185_temporal_schedule_cleanup_outbox.sql +168 -0
  95. package/drizzle/0186_sandbox_capture_provider_contract.sql +400 -0
  96. package/drizzle/0187_generated_image_artifacts.sql +173 -0
  97. package/drizzle/0188_image_generation_retention_failure.sql +25 -0
  98. package/drizzle/0189_retained_session_image_formats.sql +13 -0
  99. package/drizzle/0190_timeline_annotations.sql +31 -0
  100. package/drizzle/0191_editable_artifact_engine.sql +3853 -0
  101. package/drizzle/0192_editable_artifact_live_tickets.sql +338 -0
  102. package/drizzle/0193_editable_artifact_authorization.sql +190 -0
  103. package/drizzle/0194_editable_artifact_durable_exports.sql +160 -0
  104. package/drizzle/0195_editable_artifact_import_authorization.sql +203 -0
  105. package/drizzle/0196_rig_provider_images.sql +152 -0
  106. package/drizzle/0197_knowledge_source_sync_schedules.sql +523 -0
  107. package/drizzle/0198_memory_slack_publication_delivery.sql +393 -0
  108. package/drizzle/0199_workspace_learning_policy.sql +980 -0
  109. package/drizzle/0201_company_profile_authority.sql +849 -0
  110. package/drizzle/0202_document_index_checkpoints.sql +85 -0
  111. package/drizzle/0203_durable_video_generation.sql +353 -0
  112. package/drizzle/0204_video_generation_funding.sql +44 -0
  113. package/drizzle/0205_attempt_tool_catalogs.sql +230 -0
  114. package/drizzle/0206_browser_sessions.sql +646 -0
  115. package/drizzle/0207_browser_identities.sql +379 -0
  116. package/drizzle/0208_attached_browser_devices.sql +165 -0
  117. package/drizzle/0209_computer_sessions.sql +575 -0
  118. package/drizzle/0210_browser_auth_network_interventions.sql +441 -0
  119. package/drizzle/0211_editable_artifact_session_links.sql +58 -0
  120. package/drizzle/0212_slack_installation_bindings.sql +348 -0
  121. package/drizzle/0213_slack_user_link_access_requests.sql +177 -0
  122. package/drizzle/0214_session_activity_commit_gate.sql +262 -0
  123. package/drizzle/0215_capabilities_platform.sql +980 -0
  124. package/drizzle/0216_pack_component_ownership.sql +235 -0
  125. package/drizzle/0217_capability_definition_delete_authority.sql +32 -0
  126. package/package.json +18 -5
  127. package/src/attached-browser-devices.ts +371 -0
  128. package/src/attempt-tool-catalogs.ts +131 -0
  129. package/src/browser-auth.ts +1896 -0
  130. package/src/browser-identities.ts +1168 -0
  131. package/src/browser-sessions.ts +2005 -0
  132. package/src/browser-state-artifacts.ts +59 -0
  133. package/src/capability-components.ts +166 -0
  134. package/src/capability-integrations.ts +1663 -0
  135. package/src/codemode-operations.ts +674 -0
  136. package/src/company-profile-schema.ts +143 -0
  137. package/src/company-profile.ts +968 -0
  138. package/src/computer-sessions.ts +1130 -0
  139. package/src/connection-token-resolver.ts +270 -25
  140. package/src/database.ts +434 -24
  141. package/src/editable-artifact-durable-export.ts +1201 -0
  142. package/src/editable-artifact-materialization.ts +893 -0
  143. package/src/editable-artifacts-schema.ts +879 -0
  144. package/src/editable-artifacts.ts +5404 -0
  145. package/src/environment-crypto.ts +22 -9
  146. package/src/generated-images.ts +740 -0
  147. package/src/index.ts +10117 -2730
  148. package/src/insights.ts +160 -13
  149. package/src/integration-bindings.ts +455 -0
  150. package/src/integration-features.ts +859 -0
  151. package/src/interaction-revisions.ts +40 -0
  152. package/src/interaction-schema.ts +1303 -0
  153. package/src/knowledge-source-sync-schema.ts +192 -0
  154. package/src/knowledge-source-sync.ts +1922 -0
  155. package/src/lossless-columns.ts +35 -0
  156. package/src/lossless-json.ts +322 -0
  157. package/src/memory-domain.ts +5 -44
  158. package/src/memory-slack-delivery.ts +801 -0
  159. package/src/pack-components.ts +954 -0
  160. package/src/pack-installations.ts +929 -0
  161. package/src/persistence-errors.ts +26 -34
  162. package/src/plugin-packages.ts +1186 -0
  163. package/src/provision-roles.ts +302 -1
  164. package/src/runtime-posture-cli.ts +9 -0
  165. package/src/runtime-posture.ts +514 -5
  166. package/src/schema.ts +5177 -2957
  167. package/src/scoped-knowledge-schema.ts +1 -1
  168. package/src/scoped-knowledge.ts +655 -3
  169. package/src/session-control.ts +133 -81
  170. package/src/session-queue-commands.ts +511 -247
  171. package/src/session-realtime-context.ts +20 -7
  172. package/src/session-realtime-ledger.ts +32 -10
  173. package/src/session-realtime-mirror.ts +4 -2
  174. package/src/session-realtime-terminal.ts +89 -21
  175. package/src/session-realtime.ts +10 -9
  176. package/src/session-tool-call-settlement.ts +38 -15
  177. package/src/slack-user-link-access.ts +702 -0
  178. package/src/transcription-recordings-schema.ts +5 -2
  179. package/src/transcription-recordings.ts +70 -40
  180. package/src/video-generation.ts +1452 -0
  181. package/src/workspace-learning-policy-schema.ts +139 -0
  182. package/src/workspace-learning-policy.ts +595 -0
  183. package/dist/chunk-DBS5HPEW.js.map +0 -1
  184. package/dist/chunk-HT5T62CC.js +0 -6803
  185. package/dist/chunk-HT5T62CC.js.map +0 -1
  186. package/dist/event-payload-sanitizer.d.ts +0 -32
  187. package/src/event-payload-sanitizer.ts +0 -377
@@ -0,0 +1,1130 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ ComputerSession,
4
+ ComputerSessionCapabilities,
5
+ ComputerSessionListResponse,
6
+ ComputerSessionMutationResponse,
7
+ InteractionControllerBinding,
8
+ InteractionError,
9
+ InteractionLifecycleOperationReceipt,
10
+ type ComputerSessionCapabilities as ComputerSessionCapabilitiesValue,
11
+ type ComputerSessionListResponse as ComputerSessionListResponseValue,
12
+ type ComputerSessionMutationResponse as ComputerSessionMutationResponseValue,
13
+ type InteractionControllerBinding as InteractionControllerBindingValue,
14
+ type InteractionError as InteractionErrorValue,
15
+ type InteractionLifecycleOperationKind,
16
+ type InteractionLifecycleOperationReceipt as InteractionLifecycleOperationReceiptValue,
17
+ type InteractionPlacement,
18
+ } from "@opengeni/contracts";
19
+ import { and, desc, eq, inArray, sql } from "drizzle-orm";
20
+ import { type Database, withRlsContext } from "./database";
21
+ import {
22
+ advanceWorkspaceInteractionRevision,
23
+ readWorkspaceInteractionRevision,
24
+ } from "./interaction-revisions";
25
+ import { safeDatabaseErrorFacts } from "./persistence-errors";
26
+ import * as schema from "./schema";
27
+
28
+ type ComputerSessionRow = typeof schema.computerSessions.$inferSelect;
29
+ type ComputerAssociationRow = typeof schema.computerSessionAssociations.$inferSelect;
30
+ type InteractionOperationRow = typeof schema.interactionOperations.$inferSelect;
31
+
32
+ const CONSISTENT_READ = {
33
+ isolationLevel: "repeatable read",
34
+ accessMode: "read only",
35
+ } as const;
36
+
37
+ export class ComputerSessionNotFoundError extends Error {
38
+ readonly name = "ComputerSessionNotFoundError";
39
+ }
40
+
41
+ export class ComputerSessionOperationConflictError extends Error {
42
+ readonly name = "ComputerSessionOperationConflictError";
43
+ }
44
+
45
+ export class ComputerSessionStateError extends Error {
46
+ readonly name = "ComputerSessionStateError";
47
+ }
48
+
49
+ export type PrepareComputerSessionCreateInput = {
50
+ accountId: string;
51
+ workspaceId: string;
52
+ operationId: string;
53
+ associatedSessionId: string;
54
+ actorSubjectId: string;
55
+ name: string;
56
+ placement: InteractionPlacement;
57
+ };
58
+
59
+ export type PrepareComputerSessionEndInput = {
60
+ accountId: string;
61
+ workspaceId: string;
62
+ computerSessionId: string;
63
+ operationId: string;
64
+ actorSubjectId: string;
65
+ };
66
+
67
+ /** Internal executor view. Plaintext controller credentials are derived from
68
+ * this durable fence and never stored here. */
69
+ export type ComputerSessionControlRecord = {
70
+ session: ComputerSession;
71
+ tokenGeneration: number;
72
+ sourceSessionId: string;
73
+ createOperationId: string;
74
+ operation: null | {
75
+ operationId: string;
76
+ kind: InteractionLifecycleOperationKind;
77
+ state: InteractionOperationRow["state"];
78
+ controllerGeneration: string | null;
79
+ actorSubjectId: string;
80
+ };
81
+ };
82
+
83
+ function iso(value: Date): string {
84
+ return value.toISOString();
85
+ }
86
+
87
+ function placementFromRow(row: ComputerSessionRow): InteractionPlacement {
88
+ switch (row.placementKind) {
89
+ case "sandbox_group":
90
+ if (!row.sandboxGroupId) throw new Error("ComputerSession sandbox placement is incomplete");
91
+ return { kind: "sandbox_group", sandboxGroupId: row.sandboxGroupId };
92
+ case "connected_machine":
93
+ if (!row.connectedSandboxId) {
94
+ throw new Error("ComputerSession connected-machine placement is incomplete");
95
+ }
96
+ return { kind: "connected_machine", sandboxId: row.connectedSandboxId };
97
+ case "attached_device":
98
+ if (!row.deviceId) throw new Error("ComputerSession attached-device placement is incomplete");
99
+ return { kind: "attached_device", deviceId: row.deviceId };
100
+ case "external_provider":
101
+ if (!row.externalProviderId || !row.externalPlacementId) {
102
+ throw new Error("ComputerSession external placement is incomplete");
103
+ }
104
+ return {
105
+ kind: "external_provider",
106
+ providerId: row.externalProviderId,
107
+ placementId: row.externalPlacementId,
108
+ };
109
+ }
110
+ }
111
+
112
+ function controllerFromRow(row: ComputerSessionRow): InteractionControllerBindingValue | null {
113
+ if (!row.controllerId && !row.controllerGeneration && !row.placementInstanceId) return null;
114
+ if (!row.controllerId || !row.controllerGeneration || !row.placementInstanceId) {
115
+ throw new Error("ComputerSession controller binding is incomplete");
116
+ }
117
+ return InteractionControllerBinding.parse({
118
+ controllerId: row.controllerId,
119
+ controllerGeneration: row.controllerGeneration,
120
+ placementInstanceId: row.placementInstanceId,
121
+ });
122
+ }
123
+
124
+ function associationFromRow(row: ComputerAssociationRow) {
125
+ return {
126
+ sessionId: row.sessionId,
127
+ turnId: row.turnId,
128
+ attemptId: row.attemptId,
129
+ relationship: row.relationship,
130
+ actorSubjectId: row.actorSubjectId,
131
+ lastUsedAt: iso(row.lastUsedAt),
132
+ } as const;
133
+ }
134
+
135
+ function computerSessionFromRows(
136
+ row: ComputerSessionRow,
137
+ associations: readonly ComputerAssociationRow[],
138
+ ): ComputerSession {
139
+ return ComputerSession.parse({
140
+ id: row.id,
141
+ accountId: row.accountId,
142
+ workspaceId: row.workspaceId,
143
+ name: row.name,
144
+ lifecycle: row.lifecycle,
145
+ placement: placementFromRow(row),
146
+ controller: controllerFromRow(row),
147
+ platform: row.platform,
148
+ adapter: row.adapter,
149
+ seatId: row.seatId,
150
+ displayId: row.displayId,
151
+ capabilities: row.capabilities,
152
+ associations: associations.map(associationFromRow),
153
+ createdBySubjectId: row.createdBySubjectId,
154
+ createdAt: iso(row.createdAt),
155
+ lastUsedAt: iso(row.lastUsedAt),
156
+ failureCode: row.failureCode,
157
+ });
158
+ }
159
+
160
+ function operationReceipt(
161
+ row: InteractionOperationRow,
162
+ replayed: boolean,
163
+ ): InteractionLifecycleOperationReceiptValue {
164
+ const error: InteractionErrorValue | null = row.errorCode
165
+ ? InteractionError.parse({
166
+ code: row.errorCode,
167
+ message: row.errorMessage,
168
+ retryable: row.errorRetryable,
169
+ ...(row.errorDetails ? { details: row.errorDetails } : {}),
170
+ })
171
+ : null;
172
+ return InteractionLifecycleOperationReceipt.parse({
173
+ operationId: row.operationId,
174
+ resourceKind: row.resourceKind,
175
+ resourceId: row.resourceId,
176
+ kind: row.kind,
177
+ state: row.state,
178
+ replayed,
179
+ error,
180
+ createdAt: iso(row.createdAt),
181
+ dispatchedAt: row.dispatchedAt ? iso(row.dispatchedAt) : null,
182
+ settledAt: row.settledAt ? iso(row.settledAt) : null,
183
+ });
184
+ }
185
+
186
+ async function loadAssociations(
187
+ db: Database,
188
+ workspaceId: string,
189
+ computerSessionIds: readonly string[],
190
+ ): Promise<ComputerAssociationRow[]> {
191
+ if (computerSessionIds.length === 0) return [];
192
+ return await db
193
+ .select()
194
+ .from(schema.computerSessionAssociations)
195
+ .where(
196
+ and(
197
+ eq(schema.computerSessionAssociations.workspaceId, workspaceId),
198
+ inArray(schema.computerSessionAssociations.computerSessionId, [...computerSessionIds]),
199
+ ),
200
+ )
201
+ .orderBy(
202
+ desc(schema.computerSessionAssociations.lastUsedAt),
203
+ desc(schema.computerSessionAssociations.createdAt),
204
+ );
205
+ }
206
+
207
+ async function loadComputerSession(
208
+ db: Database,
209
+ workspaceId: string,
210
+ computerSessionId: string,
211
+ ): Promise<ComputerSession | null> {
212
+ const [row] = await db
213
+ .select()
214
+ .from(schema.computerSessions)
215
+ .where(
216
+ and(
217
+ eq(schema.computerSessions.workspaceId, workspaceId),
218
+ eq(schema.computerSessions.id, computerSessionId),
219
+ ),
220
+ )
221
+ .limit(1);
222
+ if (!row) return null;
223
+ return computerSessionFromRows(row, await loadAssociations(db, workspaceId, [computerSessionId]));
224
+ }
225
+
226
+ async function loadOperation(
227
+ db: Database,
228
+ workspaceId: string,
229
+ operationId: string,
230
+ ): Promise<InteractionOperationRow | null> {
231
+ const [row] = await db
232
+ .select()
233
+ .from(schema.interactionOperations)
234
+ .where(
235
+ and(
236
+ eq(schema.interactionOperations.workspaceId, workspaceId),
237
+ eq(schema.interactionOperations.operationId, operationId),
238
+ ),
239
+ )
240
+ .limit(1);
241
+ return row ?? null;
242
+ }
243
+
244
+ function requestDigest(value: Record<string, unknown>): string {
245
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
246
+ }
247
+
248
+ export function computerSessionCreateRequestDigest(
249
+ input: PrepareComputerSessionCreateInput,
250
+ ): string {
251
+ return requestDigest({
252
+ version: 1,
253
+ associatedSessionId: input.associatedSessionId,
254
+ name: input.name,
255
+ placement: input.placement,
256
+ actorSubjectId: input.actorSubjectId,
257
+ });
258
+ }
259
+
260
+ function lifecycleRequestDigest(
261
+ computerSessionId: string,
262
+ kind: InteractionLifecycleOperationKind,
263
+ actorSubjectId: string,
264
+ ): string {
265
+ return requestDigest({ version: 1, computerSessionId, kind, actorSubjectId });
266
+ }
267
+
268
+ async function replayedMutation(
269
+ db: Database,
270
+ workspaceId: string,
271
+ operation: InteractionOperationRow,
272
+ expected: { kind: InteractionLifecycleOperationKind; digest: string },
273
+ ): Promise<ComputerSessionMutationResponseValue> {
274
+ if (
275
+ operation.resourceKind !== "computer_session" ||
276
+ operation.kind !== expected.kind ||
277
+ operation.requestDigest !== expected.digest
278
+ ) {
279
+ throw new ComputerSessionOperationConflictError(
280
+ "Interaction operation id is already bound to another request",
281
+ );
282
+ }
283
+ const session = await loadComputerSession(db, workspaceId, operation.resourceId);
284
+ if (!session) throw new Error("Interaction operation references a missing ComputerSession");
285
+ return ComputerSessionMutationResponse.parse({
286
+ session,
287
+ operation: operationReceipt(operation, true),
288
+ });
289
+ }
290
+
291
+ export async function listComputerSessions(
292
+ db: Database,
293
+ input: { accountId: string; workspaceId: string },
294
+ ): Promise<ComputerSessionListResponseValue> {
295
+ return await withRlsContext(
296
+ db,
297
+ input,
298
+ async (scopedDb) => {
299
+ const rows = await scopedDb
300
+ .select()
301
+ .from(schema.computerSessions)
302
+ .where(eq(schema.computerSessions.workspaceId, input.workspaceId))
303
+ .orderBy(desc(schema.computerSessions.lastUsedAt), desc(schema.computerSessions.id));
304
+ const associations = await loadAssociations(
305
+ scopedDb,
306
+ input.workspaceId,
307
+ rows.map((row) => row.id),
308
+ );
309
+ const associationsByResource = new Map<string, ComputerAssociationRow[]>();
310
+ for (const association of associations) {
311
+ const current = associationsByResource.get(association.computerSessionId) ?? [];
312
+ current.push(association);
313
+ associationsByResource.set(association.computerSessionId, current);
314
+ }
315
+ return ComputerSessionListResponse.parse({
316
+ revision: await readWorkspaceInteractionRevision(scopedDb, input.workspaceId),
317
+ sessions: rows.map((row) =>
318
+ computerSessionFromRows(row, associationsByResource.get(row.id) ?? []),
319
+ ),
320
+ });
321
+ },
322
+ CONSISTENT_READ,
323
+ );
324
+ }
325
+
326
+ export async function getComputerSession(
327
+ db: Database,
328
+ input: { accountId: string; workspaceId: string; computerSessionId: string },
329
+ ): Promise<ComputerSession> {
330
+ return await withRlsContext(
331
+ db,
332
+ input,
333
+ async (scopedDb) => {
334
+ const session = await loadComputerSession(
335
+ scopedDb,
336
+ input.workspaceId,
337
+ input.computerSessionId,
338
+ );
339
+ if (!session) throw new ComputerSessionNotFoundError("ComputerSession not found");
340
+ return session;
341
+ },
342
+ CONSISTENT_READ,
343
+ );
344
+ }
345
+
346
+ async function controlRecordFromRows(
347
+ db: Database,
348
+ workspaceId: string,
349
+ row: ComputerSessionRow,
350
+ operation: InteractionOperationRow | null,
351
+ ): Promise<ComputerSessionControlRecord> {
352
+ const associations = await loadAssociations(db, workspaceId, [row.id]);
353
+ const created = associations.filter((association) => association.relationship === "created");
354
+ if (created.length !== 1) {
355
+ throw new Error("ComputerSession must have exactly one creation association");
356
+ }
357
+ return {
358
+ session: computerSessionFromRows(row, associations),
359
+ tokenGeneration: row.tokenGeneration,
360
+ sourceSessionId: created[0]!.sessionId,
361
+ createOperationId: row.createOperationId,
362
+ operation: operation
363
+ ? {
364
+ operationId: operation.operationId,
365
+ kind: operation.kind,
366
+ state: operation.state,
367
+ controllerGeneration: operation.controllerGeneration,
368
+ actorSubjectId: operation.actorSubjectId,
369
+ }
370
+ : null,
371
+ };
372
+ }
373
+
374
+ export async function getComputerSessionControlRecord(
375
+ db: Database,
376
+ input: {
377
+ accountId: string;
378
+ workspaceId: string;
379
+ computerSessionId: string;
380
+ operationId?: string;
381
+ },
382
+ ): Promise<ComputerSessionControlRecord> {
383
+ return await withRlsContext(
384
+ db,
385
+ input,
386
+ async (scopedDb) => {
387
+ const [row] = await scopedDb
388
+ .select()
389
+ .from(schema.computerSessions)
390
+ .where(
391
+ and(
392
+ eq(schema.computerSessions.accountId, input.accountId),
393
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
394
+ eq(schema.computerSessions.id, input.computerSessionId),
395
+ ),
396
+ )
397
+ .limit(1);
398
+ if (!row) throw new ComputerSessionNotFoundError("ComputerSession not found");
399
+ const operation = input.operationId
400
+ ? await loadOperation(scopedDb, input.workspaceId, input.operationId)
401
+ : null;
402
+ if (input.operationId) assertOperationResource(operation, input.computerSessionId);
403
+ return await controlRecordFromRows(scopedDb, input.workspaceId, row, operation);
404
+ },
405
+ CONSISTENT_READ,
406
+ );
407
+ }
408
+
409
+ export async function findComputerSessionControlRecordByOperation(
410
+ db: Database,
411
+ input: { accountId: string; workspaceId: string; operationId: string },
412
+ ): Promise<ComputerSessionControlRecord | null> {
413
+ return await withRlsContext(
414
+ db,
415
+ input,
416
+ async (scopedDb) => {
417
+ const operation = await loadOperation(scopedDb, input.workspaceId, input.operationId);
418
+ if (!operation) return null;
419
+ if (operation.resourceKind !== "computer_session") {
420
+ throw new ComputerSessionOperationConflictError(
421
+ "Interaction operation is bound to another resource",
422
+ );
423
+ }
424
+ const [row] = await scopedDb
425
+ .select()
426
+ .from(schema.computerSessions)
427
+ .where(
428
+ and(
429
+ eq(schema.computerSessions.accountId, input.accountId),
430
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
431
+ eq(schema.computerSessions.id, operation.resourceId),
432
+ ),
433
+ )
434
+ .limit(1);
435
+ if (!row) throw new Error("ComputerSession operation references a missing resource");
436
+ return await controlRecordFromRows(scopedDb, input.workspaceId, row, operation);
437
+ },
438
+ CONSISTENT_READ,
439
+ );
440
+ }
441
+
442
+ function placementToColumns(placement: InteractionPlacement): {
443
+ placementKind: ComputerSessionRow["placementKind"];
444
+ sandboxGroupId: string | null;
445
+ connectedSandboxId: string | null;
446
+ deviceId: string | null;
447
+ externalProviderId: string | null;
448
+ externalPlacementId: string | null;
449
+ } {
450
+ return {
451
+ placementKind: placement.kind,
452
+ sandboxGroupId: placement.kind === "sandbox_group" ? placement.sandboxGroupId : null,
453
+ connectedSandboxId: placement.kind === "connected_machine" ? placement.sandboxId : null,
454
+ deviceId: placement.kind === "attached_device" ? placement.deviceId : null,
455
+ externalProviderId: placement.kind === "external_provider" ? placement.providerId : null,
456
+ externalPlacementId: placement.kind === "external_provider" ? placement.placementId : null,
457
+ };
458
+ }
459
+
460
+ export async function prepareComputerSessionCreate(
461
+ db: Database,
462
+ input: PrepareComputerSessionCreateInput,
463
+ ): Promise<ComputerSessionMutationResponseValue> {
464
+ const digest = computerSessionCreateRequestDigest(input);
465
+ const computerSessionId = randomUUID();
466
+ return await withRlsContext(db, input, async (scopedDb) =>
467
+ scopedDb.transaction(async (txRaw) => {
468
+ const tx = txRaw as unknown as Database;
469
+ await tx.execute(sql`
470
+ select id from workspaces
471
+ where id = ${input.workspaceId} and account_id = ${input.accountId}
472
+ for key share
473
+ `);
474
+ const existingOperation = await loadOperation(tx, input.workspaceId, input.operationId);
475
+ if (existingOperation) {
476
+ return await replayedMutation(tx, input.workspaceId, existingOperation, {
477
+ kind: "create",
478
+ digest,
479
+ });
480
+ }
481
+ const [sourceSession] = await tx
482
+ .select({ id: schema.sessions.id })
483
+ .from(schema.sessions)
484
+ .where(
485
+ and(
486
+ eq(schema.sessions.workspaceId, input.workspaceId),
487
+ eq(schema.sessions.id, input.associatedSessionId),
488
+ ),
489
+ )
490
+ .limit(1);
491
+ if (!sourceSession) throw new ComputerSessionNotFoundError("Associated session not found");
492
+
493
+ const [insertedOperation] = await tx
494
+ .insert(schema.interactionOperations)
495
+ .values({
496
+ operationId: input.operationId,
497
+ accountId: input.accountId,
498
+ workspaceId: input.workspaceId,
499
+ resourceKind: "computer_session",
500
+ resourceId: computerSessionId,
501
+ kind: "create",
502
+ requestDigest: digest,
503
+ state: "prepared",
504
+ actorSubjectId: input.actorSubjectId,
505
+ })
506
+ .onConflictDoNothing({ target: schema.interactionOperations.operationId })
507
+ .returning();
508
+ if (!insertedOperation) {
509
+ const existing = await loadOperation(tx, input.workspaceId, input.operationId);
510
+ if (!existing) {
511
+ throw new ComputerSessionOperationConflictError(
512
+ "Interaction operation id belongs to another workspace",
513
+ );
514
+ }
515
+ return await replayedMutation(tx, input.workspaceId, existing, {
516
+ kind: "create",
517
+ digest,
518
+ });
519
+ }
520
+
521
+ const [insertedSession] = await tx
522
+ .insert(schema.computerSessions)
523
+ .values({
524
+ id: computerSessionId,
525
+ accountId: input.accountId,
526
+ workspaceId: input.workspaceId,
527
+ name: input.name,
528
+ lifecycle: "starting",
529
+ ...placementToColumns(input.placement),
530
+ createOperationId: input.operationId,
531
+ createdBySubjectId: input.actorSubjectId,
532
+ })
533
+ .returning();
534
+ if (!insertedSession) throw new Error("ComputerSession insert did not return its row");
535
+ const [association] = await tx
536
+ .insert(schema.computerSessionAssociations)
537
+ .values({
538
+ accountId: input.accountId,
539
+ workspaceId: input.workspaceId,
540
+ computerSessionId,
541
+ sessionId: input.associatedSessionId,
542
+ turnId: null,
543
+ attemptId: null,
544
+ relationship: "created",
545
+ actorSubjectId: input.actorSubjectId,
546
+ })
547
+ .returning();
548
+ if (!association) throw new Error("ComputerSession association insert failed");
549
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
550
+ return ComputerSessionMutationResponse.parse({
551
+ session: computerSessionFromRows(insertedSession, [association]),
552
+ operation: operationReceipt(insertedOperation, false),
553
+ });
554
+ }),
555
+ );
556
+ }
557
+
558
+ export async function dispatchComputerSessionOperation(
559
+ db: Database,
560
+ input: {
561
+ accountId: string;
562
+ workspaceId: string;
563
+ operationId: string;
564
+ computerSessionId: string;
565
+ controllerGeneration: string;
566
+ controller?: InteractionControllerBindingValue;
567
+ },
568
+ ): Promise<InteractionLifecycleOperationReceiptValue> {
569
+ const controller = input.controller ? InteractionControllerBinding.parse(input.controller) : null;
570
+ if (controller && controller.controllerGeneration !== input.controllerGeneration) {
571
+ throw new ComputerSessionOperationConflictError(
572
+ "ComputerSession dispatch controller generations disagree",
573
+ );
574
+ }
575
+ return await withRlsContext(db, input, async (scopedDb) =>
576
+ scopedDb.transaction(async (txRaw) => {
577
+ const tx = txRaw as unknown as Database;
578
+ await lockOperation(tx, input.workspaceId, input.operationId);
579
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
580
+ assertOperationResource(operation, input.computerSessionId);
581
+ if (operation.state === "prepared") {
582
+ if (controller) {
583
+ await lockComputerSession(tx, input.workspaceId, input.computerSessionId);
584
+ const now = new Date();
585
+ const [bound] = await tx
586
+ .update(schema.computerSessions)
587
+ .set({
588
+ controllerId: controller.controllerId,
589
+ controllerGeneration: controller.controllerGeneration,
590
+ placementInstanceId: controller.placementInstanceId,
591
+ controllerHeartbeatAt: now,
592
+ updatedAt: now,
593
+ })
594
+ .where(
595
+ and(
596
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
597
+ eq(schema.computerSessions.id, input.computerSessionId),
598
+ eq(schema.computerSessions.lifecycle, "starting"),
599
+ ),
600
+ )
601
+ .returning();
602
+ if (!bound) {
603
+ throw new ComputerSessionStateError(
604
+ "ComputerSession cannot accept a controller dispatch binding",
605
+ );
606
+ }
607
+ }
608
+ const now = new Date();
609
+ const [updated] = await tx
610
+ .update(schema.interactionOperations)
611
+ .set({
612
+ state: "dispatched",
613
+ controllerGeneration: input.controllerGeneration,
614
+ dispatchedAt: now,
615
+ updatedAt: now,
616
+ })
617
+ .where(eq(schema.interactionOperations.operationId, input.operationId))
618
+ .returning();
619
+ if (!updated) throw new Error("ComputerSession operation dispatch was lost");
620
+ return operationReceipt(updated, false);
621
+ }
622
+ if (
623
+ operation.controllerGeneration !== null &&
624
+ operation.controllerGeneration !== input.controllerGeneration
625
+ ) {
626
+ throw new ComputerSessionOperationConflictError(
627
+ "ComputerSession operation belongs to another controller generation",
628
+ );
629
+ }
630
+ if (controller) {
631
+ const [row] = await tx
632
+ .select()
633
+ .from(schema.computerSessions)
634
+ .where(
635
+ and(
636
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
637
+ eq(schema.computerSessions.id, input.computerSessionId),
638
+ ),
639
+ )
640
+ .limit(1);
641
+ const current = row ? controllerFromRow(row) : null;
642
+ if (
643
+ !current ||
644
+ current.controllerId !== controller.controllerId ||
645
+ current.controllerGeneration !== controller.controllerGeneration ||
646
+ current.placementInstanceId !== controller.placementInstanceId
647
+ ) {
648
+ throw new ComputerSessionOperationConflictError(
649
+ "ComputerSession dispatch belongs to another controller binding",
650
+ );
651
+ }
652
+ }
653
+ return operationReceipt(operation, true);
654
+ }),
655
+ );
656
+ }
657
+
658
+ export async function activateComputerSession(
659
+ db: Database,
660
+ input: {
661
+ accountId: string;
662
+ workspaceId: string;
663
+ operationId: string;
664
+ computerSessionId: string;
665
+ controller: InteractionControllerBindingValue;
666
+ platform: "linux" | "macos" | "windows";
667
+ adapter: string;
668
+ seatId: string;
669
+ displayId: string;
670
+ capabilities: ComputerSessionCapabilitiesValue;
671
+ },
672
+ ): Promise<ComputerSessionMutationResponseValue> {
673
+ const controller = InteractionControllerBinding.parse(input.controller);
674
+ const capabilities = ComputerSessionCapabilities.parse(input.capabilities);
675
+ return await withRlsContext(db, input, async (scopedDb) =>
676
+ scopedDb.transaction(async (txRaw) => {
677
+ const tx = txRaw as unknown as Database;
678
+ await lockOperation(tx, input.workspaceId, input.operationId);
679
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
680
+ assertOperationResource(operation, input.computerSessionId);
681
+ if (operation.state === "completed") {
682
+ return await replayedMutation(tx, input.workspaceId, operation, {
683
+ kind: operation.kind,
684
+ digest: operation.requestDigest,
685
+ });
686
+ }
687
+ if (operation.state !== "prepared" && operation.state !== "dispatched") {
688
+ throw new ComputerSessionStateError("ComputerSession operation is already terminal");
689
+ }
690
+ if (
691
+ operation.controllerGeneration !== null &&
692
+ operation.controllerGeneration !== controller.controllerGeneration
693
+ ) {
694
+ throw new ComputerSessionOperationConflictError(
695
+ "ComputerSession activation has a stale controller generation",
696
+ );
697
+ }
698
+ await lockComputerSession(tx, input.workspaceId, input.computerSessionId);
699
+ const now = new Date();
700
+ const [sessionRow] = await tx
701
+ .update(schema.computerSessions)
702
+ .set({
703
+ lifecycle: "active",
704
+ controllerId: controller.controllerId,
705
+ controllerGeneration: controller.controllerGeneration,
706
+ placementInstanceId: controller.placementInstanceId,
707
+ platform: input.platform,
708
+ adapter: input.adapter,
709
+ seatId: input.seatId,
710
+ displayId: input.displayId,
711
+ capabilities,
712
+ controllerHeartbeatAt: now,
713
+ lastUsedAt: now,
714
+ failureCode: null,
715
+ updatedAt: now,
716
+ })
717
+ .where(
718
+ and(
719
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
720
+ eq(schema.computerSessions.id, input.computerSessionId),
721
+ inArray(schema.computerSessions.lifecycle, ["starting", "active"]),
722
+ ),
723
+ )
724
+ .returning();
725
+ if (!sessionRow) throw new ComputerSessionStateError("ComputerSession cannot be activated");
726
+ const [operationRow] = await tx
727
+ .update(schema.interactionOperations)
728
+ .set({
729
+ state: "completed",
730
+ controllerGeneration: controller.controllerGeneration,
731
+ dispatchedAt: operation.dispatchedAt ?? now,
732
+ settledAt: now,
733
+ updatedAt: now,
734
+ })
735
+ .where(eq(schema.interactionOperations.operationId, input.operationId))
736
+ .returning();
737
+ if (!operationRow) throw new Error("ComputerSession operation completion was lost");
738
+ const associations = await loadAssociations(tx, input.workspaceId, [input.computerSessionId]);
739
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
740
+ return ComputerSessionMutationResponse.parse({
741
+ session: computerSessionFromRows(sessionRow, associations),
742
+ operation: operationReceipt(operationRow, false),
743
+ });
744
+ }),
745
+ );
746
+ }
747
+
748
+ export async function failComputerSessionOperation(
749
+ db: Database,
750
+ input: {
751
+ accountId: string;
752
+ workspaceId: string;
753
+ operationId: string;
754
+ computerSessionId: string;
755
+ state?: "failed" | "outcome_unknown";
756
+ error: InteractionErrorValue;
757
+ },
758
+ ): Promise<ComputerSessionMutationResponseValue> {
759
+ const failure = InteractionError.parse(input.error);
760
+ return await withRlsContext(db, input, async (scopedDb) =>
761
+ scopedDb.transaction(async (txRaw) => {
762
+ const tx = txRaw as unknown as Database;
763
+ await lockOperation(tx, input.workspaceId, input.operationId);
764
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
765
+ assertOperationResource(operation, input.computerSessionId);
766
+ if (["completed", "failed", "outcome_unknown"].includes(operation.state)) {
767
+ return await replayedMutation(tx, input.workspaceId, operation, {
768
+ kind: operation.kind,
769
+ digest: operation.requestDigest,
770
+ });
771
+ }
772
+ await lockComputerSession(tx, input.workspaceId, input.computerSessionId);
773
+ const now = new Date();
774
+ const uncertain = input.state === "outcome_unknown";
775
+ const [sessionRow] = await tx
776
+ .update(schema.computerSessions)
777
+ .set({
778
+ lifecycle: uncertain ? "lost" : "failed",
779
+ ...(uncertain
780
+ ? {}
781
+ : {
782
+ controllerId: null,
783
+ controllerGeneration: null,
784
+ placementInstanceId: null,
785
+ controllerHeartbeatAt: null,
786
+ }),
787
+ failureCode: failure.code,
788
+ updatedAt: now,
789
+ })
790
+ .where(
791
+ and(
792
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
793
+ eq(schema.computerSessions.id, input.computerSessionId),
794
+ ),
795
+ )
796
+ .returning();
797
+ const [operationRow] = await tx
798
+ .update(schema.interactionOperations)
799
+ .set({
800
+ state: input.state ?? "failed",
801
+ errorCode: failure.code,
802
+ errorMessage: failure.message,
803
+ errorRetryable: failure.retryable,
804
+ errorDetails: failure.details ?? null,
805
+ settledAt: now,
806
+ updatedAt: now,
807
+ })
808
+ .where(eq(schema.interactionOperations.operationId, input.operationId))
809
+ .returning();
810
+ if (!sessionRow || !operationRow) {
811
+ throw new Error("ComputerSession failure settlement was lost");
812
+ }
813
+ const associations = await loadAssociations(tx, input.workspaceId, [input.computerSessionId]);
814
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
815
+ return ComputerSessionMutationResponse.parse({
816
+ session: computerSessionFromRows(sessionRow, associations),
817
+ operation: operationReceipt(operationRow, false),
818
+ });
819
+ }),
820
+ );
821
+ }
822
+
823
+ export async function prepareComputerSessionEnd(
824
+ db: Database,
825
+ input: PrepareComputerSessionEndInput,
826
+ ): Promise<ComputerSessionMutationResponseValue> {
827
+ const digest = lifecycleRequestDigest(input.computerSessionId, "end", input.actorSubjectId);
828
+ try {
829
+ return await withRlsContext(db, input, async (scopedDb) =>
830
+ scopedDb.transaction(async (txRaw) => {
831
+ const tx = txRaw as unknown as Database;
832
+ await lockComputerSession(tx, input.workspaceId, input.computerSessionId);
833
+ const session = await loadComputerSession(tx, input.workspaceId, input.computerSessionId);
834
+ if (!session) throw new ComputerSessionNotFoundError("ComputerSession not found");
835
+ const [dependentBrowser] = await tx
836
+ .select({ id: schema.browserSessions.id })
837
+ .from(schema.browserSessions)
838
+ .where(
839
+ and(
840
+ eq(schema.browserSessions.workspaceId, input.workspaceId),
841
+ eq(schema.browserSessions.linkedComputerSessionId, input.computerSessionId),
842
+ inArray(schema.browserSessions.lifecycle, [
843
+ "starting",
844
+ "active",
845
+ "suspending",
846
+ "suspended",
847
+ "restoring",
848
+ "repair_required",
849
+ "lost",
850
+ "ending",
851
+ ]),
852
+ ),
853
+ )
854
+ .limit(1);
855
+ if (dependentBrowser) {
856
+ throw new ComputerSessionStateError(
857
+ "ComputerSession is still used by a BrowserSession; end the browser first",
858
+ );
859
+ }
860
+ const now = new Date();
861
+ const terminal = session.lifecycle === "ended";
862
+ const [insertedOperation] = await tx
863
+ .insert(schema.interactionOperations)
864
+ .values({
865
+ operationId: input.operationId,
866
+ accountId: input.accountId,
867
+ workspaceId: input.workspaceId,
868
+ resourceKind: "computer_session",
869
+ resourceId: input.computerSessionId,
870
+ kind: "end",
871
+ requestDigest: digest,
872
+ state: terminal ? "completed" : "prepared",
873
+ actorSubjectId: input.actorSubjectId,
874
+ ...(terminal ? { dispatchedAt: now, settledAt: now } : {}),
875
+ })
876
+ .onConflictDoNothing({ target: schema.interactionOperations.operationId })
877
+ .returning();
878
+ if (!insertedOperation) {
879
+ const existing = await loadOperation(tx, input.workspaceId, input.operationId);
880
+ if (!existing) {
881
+ throw new ComputerSessionOperationConflictError(
882
+ "Interaction operation id belongs to another workspace",
883
+ );
884
+ }
885
+ return await replayedMutation(tx, input.workspaceId, existing, {
886
+ kind: "end",
887
+ digest,
888
+ });
889
+ }
890
+ if (terminal) {
891
+ return ComputerSessionMutationResponse.parse({
892
+ session,
893
+ operation: operationReceipt(insertedOperation, false),
894
+ });
895
+ }
896
+ const [sessionRow] = await tx
897
+ .update(schema.computerSessions)
898
+ .set({ lifecycle: "ending", failureCode: null, updatedAt: now })
899
+ .where(
900
+ and(
901
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
902
+ eq(schema.computerSessions.id, input.computerSessionId),
903
+ ),
904
+ )
905
+ .returning();
906
+ if (!sessionRow) throw new ComputerSessionNotFoundError("ComputerSession not found");
907
+ const associations = await loadAssociations(tx, input.workspaceId, [
908
+ input.computerSessionId,
909
+ ]);
910
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
911
+ return ComputerSessionMutationResponse.parse({
912
+ session: computerSessionFromRows(sessionRow, associations),
913
+ operation: operationReceipt(insertedOperation, false),
914
+ });
915
+ }),
916
+ );
917
+ } catch (error) {
918
+ if (safeDatabaseErrorFacts(error).constraint === "interaction_operations_active_resource_uq") {
919
+ throw new ComputerSessionOperationConflictError(
920
+ "ComputerSession already has an active lifecycle operation",
921
+ );
922
+ }
923
+ throw error;
924
+ }
925
+ }
926
+
927
+ export async function completeComputerSessionEnd(
928
+ db: Database,
929
+ input: {
930
+ accountId: string;
931
+ workspaceId: string;
932
+ operationId: string;
933
+ computerSessionId: string;
934
+ expectedControllerGeneration: string | null;
935
+ },
936
+ ): Promise<ComputerSessionMutationResponseValue> {
937
+ return await withRlsContext(db, input, async (scopedDb) =>
938
+ scopedDb.transaction(async (txRaw) => {
939
+ const tx = txRaw as unknown as Database;
940
+ await lockOperation(tx, input.workspaceId, input.operationId);
941
+ const operation = await loadOperation(tx, input.workspaceId, input.operationId);
942
+ assertOperationResource(operation, input.computerSessionId, "end");
943
+ if (operation.state === "completed") {
944
+ return await replayedMutation(tx, input.workspaceId, operation, {
945
+ kind: "end",
946
+ digest: operation.requestDigest,
947
+ });
948
+ }
949
+ if (operation.state !== "prepared" && operation.state !== "dispatched") {
950
+ throw new ComputerSessionStateError("ComputerSession end operation is already terminal");
951
+ }
952
+ await lockComputerSession(tx, input.workspaceId, input.computerSessionId);
953
+ const [current] = await tx
954
+ .select()
955
+ .from(schema.computerSessions)
956
+ .where(
957
+ and(
958
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
959
+ eq(schema.computerSessions.id, input.computerSessionId),
960
+ ),
961
+ )
962
+ .limit(1);
963
+ if (!current) throw new ComputerSessionNotFoundError("ComputerSession not found");
964
+ if (current.controllerGeneration !== input.expectedControllerGeneration) {
965
+ throw new ComputerSessionOperationConflictError(
966
+ "ComputerSession end has a stale controller generation",
967
+ );
968
+ }
969
+ const now = new Date();
970
+ const [sessionRow] = await tx
971
+ .update(schema.computerSessions)
972
+ .set({
973
+ lifecycle: "ended",
974
+ controllerId: null,
975
+ controllerGeneration: null,
976
+ placementInstanceId: null,
977
+ controllerHeartbeatAt: null,
978
+ failureCode: null,
979
+ updatedAt: now,
980
+ })
981
+ .where(
982
+ and(
983
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
984
+ eq(schema.computerSessions.id, input.computerSessionId),
985
+ ),
986
+ )
987
+ .returning();
988
+ const [operationRow] = await tx
989
+ .update(schema.interactionOperations)
990
+ .set({
991
+ state: "completed",
992
+ controllerGeneration: input.expectedControllerGeneration,
993
+ dispatchedAt: operation.dispatchedAt ?? now,
994
+ settledAt: now,
995
+ updatedAt: now,
996
+ })
997
+ .where(
998
+ and(
999
+ eq(schema.interactionOperations.workspaceId, input.workspaceId),
1000
+ eq(schema.interactionOperations.operationId, input.operationId),
1001
+ ),
1002
+ )
1003
+ .returning();
1004
+ if (!sessionRow || !operationRow) throw new Error("ComputerSession end completion was lost");
1005
+ const associations = await loadAssociations(tx, input.workspaceId, [input.computerSessionId]);
1006
+ await advanceWorkspaceInteractionRevision(tx, input.accountId, input.workspaceId);
1007
+ return ComputerSessionMutationResponse.parse({
1008
+ session: computerSessionFromRows(sessionRow, associations),
1009
+ operation: operationReceipt(operationRow, false),
1010
+ });
1011
+ }),
1012
+ );
1013
+ }
1014
+
1015
+ export async function touchComputerSessionController(
1016
+ db: Database,
1017
+ input: {
1018
+ accountId: string;
1019
+ workspaceId: string;
1020
+ computerSessionId: string;
1021
+ controllerGeneration: string;
1022
+ },
1023
+ ): Promise<boolean> {
1024
+ return await withRlsContext(db, input, async (scopedDb) =>
1025
+ scopedDb.transaction(async (txRaw) => {
1026
+ const tx = txRaw as unknown as Database;
1027
+ const [observed] = await tx
1028
+ .select()
1029
+ .from(schema.computerSessions)
1030
+ .where(
1031
+ and(
1032
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
1033
+ eq(schema.computerSessions.id, input.computerSessionId),
1034
+ eq(schema.computerSessions.lifecycle, "active"),
1035
+ eq(schema.computerSessions.controllerGeneration, input.controllerGeneration),
1036
+ ),
1037
+ )
1038
+ .limit(1);
1039
+ if (!observed) return false;
1040
+ if (observed.placementKind === "sandbox_group") {
1041
+ const lease = await tx.execute<{ id: string }>(sql`
1042
+ select lease.id
1043
+ from sandbox_leases lease
1044
+ join computer_sessions computer
1045
+ on computer.account_id = lease.account_id
1046
+ and computer.workspace_id = lease.workspace_id
1047
+ and computer.sandbox_group_id = lease.sandbox_group_id
1048
+ where computer.account_id = ${input.accountId}
1049
+ and computer.workspace_id = ${input.workspaceId}
1050
+ and computer.id = ${input.computerSessionId}
1051
+ and computer.lifecycle = 'active'
1052
+ and computer.controller_generation = ${input.controllerGeneration}
1053
+ and computer.placement_instance_id = lease.instance_id
1054
+ for update of lease
1055
+ `);
1056
+ if (!lease[0]) return false;
1057
+ const holder = await tx.execute<{ id: string }>(sql`
1058
+ update sandbox_lease_holders holder set last_heartbeat_at = now()
1059
+ from sandbox_leases lease
1060
+ where lease.id = holder.lease_id
1061
+ and holder.account_id = ${input.accountId}
1062
+ and holder.workspace_id = ${input.workspaceId}
1063
+ and holder.kind = 'interaction'
1064
+ and holder.holder_id = ${`computer-session:${input.computerSessionId}`}
1065
+ and lease.workspace_id = ${input.workspaceId}
1066
+ and lease.sandbox_group_id = ${observed.sandboxGroupId}
1067
+ and lease.instance_id = ${observed.placementInstanceId}
1068
+ returning holder.id
1069
+ `);
1070
+ if (!holder[0]) return false;
1071
+ }
1072
+ await lockComputerSession(tx, input.workspaceId, input.computerSessionId);
1073
+ const now = new Date();
1074
+ const [updated] = await tx
1075
+ .update(schema.computerSessions)
1076
+ .set({ controllerHeartbeatAt: now, lastUsedAt: now, updatedAt: now })
1077
+ .where(
1078
+ and(
1079
+ eq(schema.computerSessions.workspaceId, input.workspaceId),
1080
+ eq(schema.computerSessions.id, input.computerSessionId),
1081
+ eq(schema.computerSessions.lifecycle, "active"),
1082
+ eq(schema.computerSessions.controllerGeneration, input.controllerGeneration),
1083
+ ),
1084
+ )
1085
+ .returning({ id: schema.computerSessions.id });
1086
+ return updated !== undefined;
1087
+ }),
1088
+ );
1089
+ }
1090
+
1091
+ async function lockOperation(
1092
+ db: Database,
1093
+ workspaceId: string,
1094
+ operationId: string,
1095
+ ): Promise<void> {
1096
+ await db.execute(sql`
1097
+ select operation_id from interaction_operations
1098
+ where workspace_id = ${workspaceId} and operation_id = ${operationId}
1099
+ for update
1100
+ `);
1101
+ }
1102
+
1103
+ async function lockComputerSession(
1104
+ db: Database,
1105
+ workspaceId: string,
1106
+ computerSessionId: string,
1107
+ ): Promise<void> {
1108
+ await db.execute(sql`
1109
+ select id from computer_sessions
1110
+ where workspace_id = ${workspaceId} and id = ${computerSessionId}
1111
+ for update
1112
+ `);
1113
+ }
1114
+
1115
+ function assertOperationResource(
1116
+ operation: InteractionOperationRow | null,
1117
+ computerSessionId: string,
1118
+ kind?: InteractionLifecycleOperationKind,
1119
+ ): asserts operation is InteractionOperationRow {
1120
+ if (!operation) throw new ComputerSessionNotFoundError("ComputerSession operation not found");
1121
+ if (
1122
+ operation.resourceKind !== "computer_session" ||
1123
+ operation.resourceId !== computerSessionId ||
1124
+ (kind !== undefined && operation.kind !== kind)
1125
+ ) {
1126
+ throw new ComputerSessionOperationConflictError(
1127
+ "Interaction operation is bound to another resource",
1128
+ );
1129
+ }
1130
+ }