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