@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.
- package/dist/attached-browser-devices.d.ts +42 -0
- package/dist/attempt-tool-catalogs.d.ts +16 -0
- package/dist/browser-auth.d.ts +91 -0
- package/dist/browser-identities.d.ts +128 -0
- package/dist/browser-sessions.d.ts +272 -0
- package/dist/browser-state-artifacts.d.ts +17 -0
- package/dist/capability-components.d.ts +37 -0
- package/dist/capability-integrations.d.ts +192 -0
- package/dist/chunk-F5FT7BEZ.js +3535 -0
- package/dist/chunk-F5FT7BEZ.js.map +1 -0
- package/dist/chunk-ISI2TIUG.js +639 -0
- package/dist/chunk-ISI2TIUG.js.map +1 -0
- package/dist/chunk-MWDVXQOL.js +11639 -0
- package/dist/chunk-MWDVXQOL.js.map +1 -0
- package/dist/chunk-NBE6CHRI.js +955 -0
- package/dist/chunk-NBE6CHRI.js.map +1 -0
- package/dist/{chunk-VHBFHMPC.js → chunk-YHZXQ7HP.js} +754 -11
- package/dist/chunk-YHZXQ7HP.js.map +1 -0
- package/dist/codemode-operations.d.ts +117 -0
- package/dist/codex-token-resolver.d.ts +2 -2
- package/dist/company-profile-schema.d.ts +798 -0
- package/dist/company-profile.d.ts +268 -0
- package/dist/computer-sessions.d.ts +109 -0
- package/dist/connection-token-resolver.d.ts +9 -4
- package/dist/database.d.ts +52 -3
- package/dist/editable-artifact-durable-export.d.ts +153 -0
- package/dist/editable-artifact-durable-export.js +788 -0
- package/dist/editable-artifact-durable-export.js.map +1 -0
- package/dist/editable-artifact-materialization.d.ts +123 -0
- package/dist/editable-artifacts-schema.d.ts +4152 -0
- package/dist/editable-artifacts.d.ts +610 -0
- package/dist/editable-artifacts.js +26 -0
- package/dist/editable-artifacts.js.map +1 -0
- package/dist/generated-images.d.ts +139 -0
- package/dist/index.d.ts +698 -111
- package/dist/index.js +27573 -10322
- package/dist/index.js.map +1 -1
- package/dist/insights.d.ts +51 -4
- package/dist/integration-bindings.d.ts +70 -0
- package/dist/integration-features.d.ts +99 -0
- package/dist/interaction-revisions.d.ts +3 -0
- package/dist/interaction-schema.d.ts +5372 -0
- package/dist/knowledge-source-sync-schema.d.ts +1679 -0
- package/dist/knowledge-source-sync.d.ts +274 -0
- package/dist/memory-governance.d.ts +2 -2
- package/dist/memory-slack-delivery.d.ts +101 -0
- package/dist/new-session-drafts.d.ts +2 -2
- package/dist/pack-components.d.ts +76 -0
- package/dist/pack-installations.d.ts +86 -0
- package/dist/persistence-errors.d.ts +3 -3
- package/dist/plugin-packages.d.ts +126 -0
- package/dist/preference-registry.d.ts +1 -1
- package/dist/provision-roles.d.ts +9 -1
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +18 -6
- package/dist/schema.d.ts +18342 -9099
- package/dist/schema.js +165 -1
- package/dist/scoped-knowledge.d.ts +97 -1
- package/dist/session-control.d.ts +4 -4
- package/dist/session-queue-commands.d.ts +19 -11
- package/dist/session-realtime-context.d.ts +2 -2
- package/dist/session-realtime-ledger.d.ts +4 -4
- package/dist/session-realtime-mirror.d.ts +2 -2
- package/dist/session-realtime-state.d.ts +1 -1
- package/dist/session-realtime-terminal.d.ts +1 -1
- package/dist/session-realtime.d.ts +7 -7
- package/dist/session-tool-call-settlement.d.ts +1 -1
- package/dist/slack-user-link-access.d.ts +54 -0
- package/dist/transcription-recordings.d.ts +2 -2
- package/dist/turn-initiator.d.ts +2 -2
- package/dist/video-generation.d.ts +216 -0
- package/dist/video-generation.js +66 -0
- package/dist/video-generation.js.map +1 -0
- package/dist/workspace-artifacts.d.ts +1 -1
- package/dist/workspace-instruction-policies.d.ts +1 -1
- package/dist/workspace-learning-policy-schema.d.ts +932 -0
- package/dist/workspace-learning-policy.d.ts +161 -0
- package/drizzle/0183_model_call_provider_cost_estimates.sql +39 -0
- package/drizzle/0184_sandbox_drain_teardown_fence.sql +682 -0
- package/drizzle/0185_temporal_schedule_cleanup_outbox.sql +168 -0
- package/drizzle/0186_sandbox_capture_provider_contract.sql +400 -0
- package/drizzle/0187_generated_image_artifacts.sql +173 -0
- package/drizzle/0188_image_generation_retention_failure.sql +25 -0
- package/drizzle/0189_retained_session_image_formats.sql +13 -0
- package/drizzle/0190_timeline_annotations.sql +31 -0
- package/drizzle/0191_editable_artifact_engine.sql +3853 -0
- package/drizzle/0192_editable_artifact_live_tickets.sql +338 -0
- package/drizzle/0193_editable_artifact_authorization.sql +190 -0
- package/drizzle/0194_editable_artifact_durable_exports.sql +160 -0
- package/drizzle/0195_editable_artifact_import_authorization.sql +203 -0
- package/drizzle/0196_rig_provider_images.sql +152 -0
- package/drizzle/0197_knowledge_source_sync_schedules.sql +523 -0
- package/drizzle/0198_memory_slack_publication_delivery.sql +393 -0
- package/drizzle/0199_workspace_learning_policy.sql +980 -0
- package/drizzle/0201_company_profile_authority.sql +849 -0
- package/drizzle/0202_document_index_checkpoints.sql +85 -0
- package/drizzle/0203_durable_video_generation.sql +353 -0
- package/drizzle/0204_video_generation_funding.sql +44 -0
- package/drizzle/0205_attempt_tool_catalogs.sql +230 -0
- package/drizzle/0206_browser_sessions.sql +646 -0
- package/drizzle/0207_browser_identities.sql +379 -0
- package/drizzle/0208_attached_browser_devices.sql +165 -0
- package/drizzle/0209_computer_sessions.sql +575 -0
- package/drizzle/0210_browser_auth_network_interventions.sql +441 -0
- package/drizzle/0211_editable_artifact_session_links.sql +58 -0
- package/drizzle/0212_slack_installation_bindings.sql +348 -0
- package/drizzle/0213_slack_user_link_access_requests.sql +177 -0
- package/drizzle/0214_session_activity_commit_gate.sql +262 -0
- package/drizzle/0215_capabilities_platform.sql +980 -0
- package/drizzle/0216_pack_component_ownership.sql +235 -0
- package/drizzle/0217_capability_definition_delete_authority.sql +32 -0
- package/package.json +18 -5
- package/src/attached-browser-devices.ts +371 -0
- package/src/attempt-tool-catalogs.ts +131 -0
- package/src/browser-auth.ts +1896 -0
- package/src/browser-identities.ts +1168 -0
- package/src/browser-sessions.ts +2005 -0
- package/src/browser-state-artifacts.ts +59 -0
- package/src/capability-components.ts +166 -0
- package/src/capability-integrations.ts +1663 -0
- package/src/codemode-operations.ts +674 -0
- package/src/company-profile-schema.ts +143 -0
- package/src/company-profile.ts +968 -0
- package/src/computer-sessions.ts +1130 -0
- package/src/connection-token-resolver.ts +270 -25
- package/src/database.ts +427 -23
- package/src/editable-artifact-durable-export.ts +1201 -0
- package/src/editable-artifact-materialization.ts +893 -0
- package/src/editable-artifacts-schema.ts +879 -0
- package/src/editable-artifacts.ts +5404 -0
- package/src/generated-images.ts +740 -0
- package/src/index.ts +5917 -1182
- package/src/insights.ts +160 -13
- package/src/integration-bindings.ts +455 -0
- package/src/integration-features.ts +859 -0
- package/src/interaction-revisions.ts +40 -0
- package/src/interaction-schema.ts +1303 -0
- package/src/knowledge-source-sync-schema.ts +192 -0
- package/src/knowledge-source-sync.ts +1922 -0
- package/src/memory-slack-delivery.ts +801 -0
- package/src/pack-components.ts +954 -0
- package/src/pack-installations.ts +929 -0
- package/src/persistence-errors.ts +4 -7
- package/src/plugin-packages.ts +1186 -0
- package/src/provision-roles.ts +302 -1
- package/src/runtime-posture-cli.ts +9 -0
- package/src/runtime-posture.ts +508 -5
- package/src/schema.ts +5117 -3119
- package/src/scoped-knowledge-schema.ts +1 -1
- package/src/scoped-knowledge.ts +655 -3
- package/src/session-control.ts +8 -5
- package/src/session-queue-commands.ts +187 -14
- package/src/session-realtime-context.ts +2 -2
- package/src/session-realtime-ledger.ts +3 -3
- package/src/session-realtime.ts +7 -7
- package/src/session-tool-call-settlement.ts +9 -0
- package/src/slack-user-link-access.ts +702 -0
- package/src/video-generation.ts +1452 -0
- package/src/workspace-learning-policy-schema.ts +139 -0
- package/src/workspace-learning-policy.ts +595 -0
- package/dist/chunk-P6CUHXQZ.js +0 -7248
- package/dist/chunk-P6CUHXQZ.js.map +0 -1
- 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
|
+
}
|