From e0e23409a46f477ffd9ef161f4b70bff3903f14e Mon Sep 17 00:00:00 2001 From: kevin9327 Date: Thu, 20 Aug 2026 20:14:23 +0900 Subject: [PATCH 1/2] Keep the snapshot a ref resolves against where every replica can read it The gateway turns the opaque ref in an acting call into the element it points at, from the snapshot this server took, and then decides and records against that element. That mapping lived in a Map in the process. OpenBot runs several server processes behind a load balancer, and the process that answers a snapshot is rarely the one that answers the click that uses its refs, so on every other replica the Map was empty: the ref resolved to nothing, the policy decided with no element in front of it, and the audit row could not name what was touched. Neither outcome is loud. A deny rule written against the element fails closed and refuses every click on the replicas that did not snapshot, which reads as the computer being flaky. A rule that does not name the element lets the click through unresolved and unrecorded, which reads as the boundary being quiet because nothing matched. Either way the boundary is not doing what the operator wrote, and nothing says so. #21 took two features back for this exact shape and noted this cache had it too. So the snapshot goes through Postgres, the way channel activity and the policy already do: one row per computer, upserted on every snapshot, read on the action path. A ref resolves on whichever replica the click lands on. Staleness, the reason a persisted snapshot cache is rightly regarded with suspicion, is answered by the generation the far-side computer stamps on every snapshot. A ref resolves only when its generation matches the stored one, so a ref from a superseded page resolves to nothing rather than to whatever now holds it: it cannot resolve to a name that is no longer on screen, because a ref from an old screen no longer matches. The computer makes the same generation check when the action reaches it; this keeps the policy decision and the audit row honest first, on whichever replica the action landed. The client-supplied snapshotId only ever narrows resolution. It can yield the true element of the current snapshot or nothing, never a different element than the ref already names, so nothing new is trusted from the client that the server does not resolve itself. Without a database the gateway still keeps snapshots in memory, so a single-process test does not need Postgres, exactly as the policy store does not. Co-Authored-By: Claude Opus 4.8 --- server/drizzle/0005_computer_snapshot.sql | 7 + server/drizzle/meta/0005_snapshot.json | 2855 +++++++++++++++++ server/drizzle/meta/_journal.json | 9 +- server/src/computer/gateway.ts | 114 +- server/src/computer/snapshot-store.ts | 162 + server/src/db/schema/computer.ts | 35 +- server/src/index.ts | 5 + server/tests/computer-gateway.test.ts | 112 +- ...omputer-snapshot-store.integration.test.ts | 117 + server/tests/computer-snapshot-store.test.ts | 88 + 10 files changed, 3468 insertions(+), 36 deletions(-) create mode 100644 server/drizzle/0005_computer_snapshot.sql create mode 100644 server/drizzle/meta/0005_snapshot.json create mode 100644 server/src/computer/snapshot-store.ts create mode 100644 server/tests/computer-snapshot-store.integration.test.ts create mode 100644 server/tests/computer-snapshot-store.test.ts diff --git a/server/drizzle/0005_computer_snapshot.sql b/server/drizzle/0005_computer_snapshot.sql new file mode 100644 index 0000000..d86da55 --- /dev/null +++ b/server/drizzle/0005_computer_snapshot.sql @@ -0,0 +1,7 @@ +CREATE TABLE "computer_snapshot" ( + "computer_id" text PRIMARY KEY NOT NULL, + "snapshot_id" integer NOT NULL, + "url" text NOT NULL, + "elements" jsonb NOT NULL, + "taken_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/server/drizzle/meta/0005_snapshot.json b/server/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..ceb5c38 --- /dev/null +++ b/server/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2855 @@ +{ + "id": "107b8166-ff54-49c7-8871-fe1b5a4fbfbf", + "prevId": "ab829598-5c7b-4ed7-9641-79d8dbe2bd19", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chunks": { + "name": "chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chunks_document_position_idx": { + "name": "chunks_document_position_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chunks_document_idx": { + "name": "chunks_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chunks_document_id_documents_id_fk": { + "name": "chunks_document_id_documents_id_fk", + "tableFrom": "chunks", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_cursors": { + "name": "connector_cursors", + "schema": "", + "columns": { + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_cursors_connector_instance_id_connector_instances_id_fk": { + "name": "connector_cursors_connector_instance_id_connector_instances_id_fk", + "tableFrom": "connector_cursors", + "tableTo": "connector_instances", + "columnsFrom": [ + "connector_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_instances": { + "name": "connector_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "connector_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_instances_credential_id_credentials_id_fk": { + "name": "connector_instances_credential_id_credentials_id_fk", + "tableFrom": "connector_instances", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_acls": { + "name": "document_acls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "acl_effect", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_acls_document_principal_effect_idx": { + "name": "document_acls_document_principal_effect_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effect", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_acls_principal_idx": { + "name": "document_acls_principal_idx", + "columns": [ + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_acls_document_id_documents_id_fk": { + "name": "document_acls_document_id_documents_id_fk", + "tableFrom": "document_acls", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_connector_source_idx": { + "name": "documents_connector_source_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_connector_deleted_idx": { + "name": "documents_connector_deleted_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_connector_instance_id_connector_instances_id_fk": { + "name": "documents_connector_instance_id_connector_instances_id_fk", + "tableFrom": "documents", + "tableTo": "connector_instances", + "columnsFrom": [ + "connector_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sync_runs_connector_started_at_idx": { + "name": "sync_runs_connector_started_at_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_runs_connector_instance_id_connector_instances_id_fk": { + "name": "sync_runs_connector_instance_id_connector_instances_id_fk", + "tableFrom": "sync_runs", + "tableTo": "connector_instances", + "columnsFrom": [ + "connector_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_subscriptions": { + "name": "webhook_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": { + "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk", + "tableFrom": "webhook_subscriptions", + "tableTo": "connector_instances", + "columnsFrom": [ + "connector_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.acl_effect": { + "name": "acl_effect", + "schema": "public", + "values": [ + "allow", + "deny" + ] + }, + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.connector_type": { + "name": "connector_type", + "schema": "public", + "values": [ + "google_drive", + "onedrive" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.sync_status": { + "name": "sync_status", + "schema": "public", + "values": [ + "pending", + "running", + "succeeded", + "failed" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 86ff22e..dcb5e64 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1787275307878, "tag": "0004_account_issuer_required", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1787322944029, + "tag": "0005_computer_snapshot", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index c8f9ecf..be73249 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -20,6 +20,7 @@ import { type AuditStore, recordAuditEvent } from "../audit"; import { ComputerUnavailableError, createComputerTransport } from "./client"; import { checkComputerAddress } from "./target"; + export { ComputerUnavailableError, ElementNotFoundError, @@ -28,6 +29,7 @@ export { WorkspaceRefusedError, WorkspaceRequestError, } from "./client"; + import { type ActionPolicy, evaluateActionPolicy, @@ -49,6 +51,8 @@ import type { ReadFileInput, ReadFileResult, ReadResult, + RunCommandInput, + RunCommandResult, ScreenshotResult, ScrollInput, SecretRequest, @@ -56,11 +60,14 @@ import type { SnapshotElement, SnapshotResult, TypeInput, - RunCommandInput, - RunCommandResult, WriteFileInput, WriteFileResult, } from "./schema"; +import { + createInMemorySnapshotStore, + type SnapshotStore, + type StoredSnapshot, +} from "./snapshot-store"; export class ActionRefusedError extends Error { /** The rule that refused it, so the surface can show which one and an operator can find it. */ @@ -92,6 +99,14 @@ export type ComputerGatewayOptions = { token?: string; /** An injectable fetch implementation for focused gateway tests. */ fetchImpl?: typeof fetch; + /** + * Where the snapshot a ref is resolved against is kept. + * + * A deployment passes the database-backed store, because the process that takes a snapshot is + * rarely the one that resolves a ref from it. Absent, the gateway keeps snapshots in memory, which + * is correct in one process and is what a unit test wants. See snapshot-store.ts. + */ + snapshots?: SnapshotStore; }; export interface ComputerGateway { @@ -188,20 +203,6 @@ export interface ComputerGateway { ): Promise<{ cleared: boolean }>; } -/** - * The last snapshot the server took, per computer. - * - * In memory. It describes the live contents of a browser window, so it is - * meaningless the moment the process holding that window restarts. Persisting it would create a cache - * that can disagree with the page, which is worse than not having one: the refs would resolve to names - * that are no longer on screen and the policy would decide on fiction. - */ -type CachedSnapshot = { - snapshotId: number; - elements: Map; - url: string; -}; - export function createComputerGateway( options: ComputerGatewayOptions, ): ComputerGateway { @@ -213,7 +214,18 @@ export function createComputerGateway( : {}), ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), }); - const snapshots = new Map(); + /** + * Where the snapshot a ref is resolved against lives. + * + * Not a `Map` in this process. It describes the live contents of a browser window, and the process + * that took it is rarely the one that resolves a ref from it: OpenBot is several servers behind a + * load balancer, and consecutive calls on one conversation land on different ones. Kept in memory, + * the mapping is absent on every replica but the one that snapshotted, so the ref resolves to + * nothing, the policy decides with no element in front of it, and the audit row cannot name what + * was touched. The store puts it in Postgres, and the generation carried on every action keeps a + * ref from a superseded page from resolving to whatever now holds it. See snapshot-store.ts. + */ + const snapshots = options.snapshots ?? createInMemorySnapshotStore(); /** * Where this Bot's computer is, checked before anything is sent to it. @@ -281,6 +293,16 @@ export function createComputerGateway( return get(botId, "/screenshot"); } + /** + * Read-only for the page, but it writes the resolution table the boundary reads. + * + * Nothing on the page changes and there is nothing to decide, so the snapshot itself passes + * straight through. What it does record is the ref-to-element mapping every later action on this + * computer is resolved against, and it records it where another replica can read it: the click that + * uses these refs will almost certainly arrive on a different process. The write is awaited before + * the refs are returned, so the snapshot cannot be resolved against on one server before it exists + * on the store. + */ async function snapshot(botId: string): Promise { const result = await transport.call( await locate(botId), @@ -288,7 +310,7 @@ export function createComputerGateway( "/snapshot", { method: "POST" }, ); - snapshots.set(botId, { + await snapshots.save(botId, { snapshotId: result.snapshotId, url: result.url, elements: new Map( @@ -303,18 +325,25 @@ export function createComputerGateway( } /** - * Resolve a ref against the snapshot the server holds. + * Resolve a ref against the snapshot the server holds, and only against the one it came from. * * Returns undefined for an unknown ref rather than throwing, because the policy still has to run: - * an action on an element we cannot identify must still receive a policy decision. - * A deny rule written against a page a Bot has not snapshotted should still refuse it. + * an action on an element we cannot identify must still receive a policy decision, and a deny rule + * written against a page a Bot has not snapshotted should still refuse it. + * + * A ref resolves only when its generation matches the stored snapshot's. A ref carrying a + * superseded generation resolves to nothing rather than to whatever now holds that ref: the policy + * must never decide on an element the caller has already scrolled off the page. The computer makes + * the same generation check when the action reaches it; this one keeps the decision and the audit + * row honest before it gets there, on whichever replica the action landed. */ function resolve( - botId: string, + stored: StoredSnapshot | undefined, ref: string | undefined, + snapshotId: number | undefined, ): SnapshotElement | undefined { - if (!ref) return undefined; - return snapshots.get(botId)?.elements.get(ref); + if (!ref || !stored || stored.snapshotId !== snapshotId) return undefined; + return stored.elements.get(ref); } /** @@ -330,6 +359,8 @@ export function createComputerGateway( actor: ActionActor, subject: { ref?: string; + /** The generation the ref came from. A ref only resolves against its own snapshot. */ + snapshotId?: number; filePath?: string; targetUrl?: string; key?: string; @@ -340,13 +371,15 @@ export function createComputerGateway( }, run: () => Promise, ): Promise { - const { ref, filePath } = subject; - const element = resolve(botId, ref); - const cached = snapshots.get(botId); + const { ref, filePath, snapshotId } = subject; + // Loaded from the store, not this process's memory: the snapshot these refs belong to was very + // likely taken by another replica, and resolving against a local map would find nothing there. + const stored = await snapshots.load(botId); + const element = resolve(stored, ref, snapshotId); // For a navigation the relevant page is the one being opened, not the one already loaded. Using - // the cached URL would mean `page.host == "..."` could never match the destination, which is the + // the stored URL would mean `page.host == "..."` could never match the destination, which is the // only thing a rule about navigation would ever want to say. - const pageUrl = subject.targetUrl ?? cached?.url ?? ""; + const pageUrl = subject.targetUrl ?? stored?.url ?? ""; const intent = intentOf(toolName, subject.key); @@ -539,7 +572,9 @@ export function createComputerGateway( */ async resetComputer(botId: string, actor: ActionActor) { const result = await provider.reset(botId); - snapshots.delete(botId); + // The refs the last snapshot handed out describe a page that no longer exists, and a fresh + // computer counts generations from one again, so the row has to go with the profile. + await snapshots.clear(botId); await writeControlEvent(auditStore, "computer.reset", { botId, actor, @@ -617,7 +652,11 @@ export function createComputerGateway( "computer_click", botId, actor, - { ref: input.ref, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + snapshotId: input.snapshotId, + ...(signal ? { signal } : {}), + }, () => post(botId, "/click", input, signal), ); }, @@ -632,7 +671,11 @@ export function createComputerGateway( "computer_type", botId, actor, - { ref: input.ref, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + snapshotId: input.snapshotId, + ...(signal ? { signal } : {}), + }, () => post(botId, "/type", input, signal), ); }, @@ -649,7 +692,12 @@ export function createComputerGateway( actor, // The key is part of the subject, so a rule can tell Enter from a letter. Form submission can // happen through a keypress as well as a click, so the policy context carries the key. - { ref: input.ref, key: input.key, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + snapshotId: input.snapshotId, + key: input.key, + ...(signal ? { signal } : {}), + }, () => post(botId, "/key", input, signal), ); }, diff --git a/server/src/computer/snapshot-store.ts b/server/src/computer/snapshot-store.ts new file mode 100644 index 0000000..7c84549 --- /dev/null +++ b/server/src/computer/snapshot-store.ts @@ -0,0 +1,162 @@ +/** + * Where the gateway keeps the snapshot it resolves refs against. + * + * A ref an acting call carries is opaque, and the gateway turns it into the element it points at by + * looking it up in the snapshot this server took. That lookup is the whole security model: a rule + * that says "never click Submit" is only worth anything if the server, not the caller, decides what + * a ref is. See the header of gateway.ts. + * + * It cannot be a `Map` in the process. OpenBot is several server processes behind a load balancer, + * and the process that answered the snapshot is rarely the one that answers the click that uses its + * refs. Held in memory, the mapping is missing on every other replica: the ref resolves to nothing, + * the policy decides with no element in front of it, and the audit row cannot say what was acted on. + * The boundary silently stops applying, which is the failure the pull request template is written to + * catch and the one #21 flagged this cache as still having. + * + * So it goes through Postgres, like channel activity and the policy before it. The table holds one + * live snapshot per computer, replaced whenever a newer one is taken. + * + * Staleness, the reason a persisted snapshot cache is rightly regarded with suspicion, is answered by + * the generation the far-side computer stamps on every snapshot: `resolve` only accepts a ref whose + * generation matches the stored one, so a ref from a superseded page resolves to nothing rather than + * to whatever now holds that ref. The refs cannot "resolve to names that are no longer on screen" + * because a ref from an old screen no longer matches. The computer itself makes the same generation + * check when the action reaches it; this keeps the policy decision and the audit row honest in the + * meantime, on whichever replica the action lands. + * + * Without a database it stays in memory. Tests that only exercise decision logic do not need + * Postgres, exactly as the policy store does not. + */ +import { eq } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { computerSnapshot } from "../db/schema"; +import type { SnapshotElement } from "./schema"; + +/** + * A snapshot as the gateway holds it: the generation, the page, and the elements keyed by ref. + * + * The elements are a `Map` here because a resolve is a single ref lookup; the table stores the same + * thing as a JSON object keyed by ref, and the two conversions live in `save` and `load`. + */ +export type StoredSnapshot = { + snapshotId: number; + url: string; + elements: Map; +}; + +export type SnapshotStore = { + /** + * Record the snapshot a computer just produced, replacing any earlier one. + * + * Awaited on the snapshot path, which is already a round trip to the computer, so the row is on + * record before the refs it describes are handed to the model and can come back on another replica. + */ + save: (computerId: string, snapshot: StoredSnapshot) => Promise; + /** The live snapshot for a computer, or undefined when none has been taken. */ + load: (computerId: string) => Promise; + /** + * Forget a computer's snapshot, because the page it described is gone. + * + * Called when a computer is wiped. Leaving the row would be worse than leaving a stale `Map` was: + * a fresh computer counts generations from one again, so a ref the model still holds from the + * previous session's first snapshot would match the row a reset left behind, and the policy would + * decide against an element from a page that no longer exists. The generation check only tells + * snapshots apart within one session, so the row has to go when the session does. + */ + clear: (computerId: string) => Promise; +}; + +/** + * The store the gateway uses in a real deployment, or an in-memory one when there is no database. + * + * The two behave the same within one process. The difference is only visible with a second one: the + * memory store cannot be read from another replica, which is precisely why a deployment passes the + * database-backed one and a single-process test does not have to. + */ +export function createSnapshotStore(database?: Database): SnapshotStore { + if (!database) return createInMemorySnapshotStore(); + + return { + save: async (computerId, snapshot) => { + const elements = objectFromElements(snapshot.elements); + const values = { + computerId, + snapshotId: snapshot.snapshotId, + url: snapshot.url, + elements, + takenAt: new Date(), + }; + await database + .insert(computerSnapshot) + .values(values) + // One row per computer. A newer snapshot supersedes the last, because a ref is only ever + // resolved against the current one; keeping the old rows would be a page history the boundary + // has no use for. + .onConflictDoUpdate({ + target: computerSnapshot.computerId, + set: { + snapshotId: values.snapshotId, + url: values.url, + elements: values.elements, + takenAt: values.takenAt, + }, + }); + }, + + clear: async (computerId) => { + await database + .delete(computerSnapshot) + .where(eq(computerSnapshot.computerId, computerId)); + }, + + load: async (computerId) => { + const [row] = await database + .select() + .from(computerSnapshot) + .where(eq(computerSnapshot.computerId, computerId)) + .limit(1); + if (!row) return undefined; + return { + snapshotId: row.snapshotId, + url: row.url, + elements: elementsFromObject( + row.elements as Record, + ), + }; + }, + }; +} + +/** + * A snapshot store that lives in one process. + * + * The gateway's default when a deployment does not pass a database, and what its unit tests use. It + * is not a fallback anybody should reach for in production: with a second replica it is the exact + * bug the database-backed store exists to fix. + */ +export function createInMemorySnapshotStore(): SnapshotStore { + const snapshots = new Map(); + return { + save: async (computerId, snapshot) => { + snapshots.set(computerId, snapshot); + }, + load: async (computerId) => snapshots.get(computerId), + clear: async (computerId) => { + snapshots.delete(computerId); + }, + }; +} + +function objectFromElements( + elements: Map, +): Record { + const object: Record = {}; + for (const [ref, element] of elements) object[ref] = element; + return object; +} + +function elementsFromObject( + object: Record, +): Map { + return new Map(Object.entries(object)); +} diff --git a/server/src/db/schema/computer.ts b/server/src/db/schema/computer.ts index 517384e..78e46dd 100644 --- a/server/src/db/schema/computer.ts +++ b/server/src/db/schema/computer.ts @@ -4,7 +4,8 @@ * Split by owner so two people can add tables all day without touching the same lines. Add tables * here; never edit core.ts or coworker.ts to do it. */ -import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { jsonb } from "./json"; /** * The boundary this deployment is enforcing, kept where a restart cannot lose it. @@ -33,3 +34,35 @@ export const actionPolicy = pgTable("action_policy", { .notNull() .defaultNow(), }); + +/** + * The last snapshot a computer produced, kept where every replica can read it. + * + * The gateway resolves the opaque ref in an acting call into the element it points at, and it must + * resolve it against the snapshot the ref came from, never against a label the caller supplied. That + * mapping used to live in a `Map` inside one process. OpenBot runs several processes behind a load + * balancer, and the process that took the snapshot is rarely the one that handles the click that + * follows it, so the mapping was absent exactly when a click arrived on another replica: the policy + * then decided with no element in front of it and the audit row could not name what was touched. The + * boundary the whole gateway exists for stopped applying, and nothing said so. + * + * One row per computer, upserted on every snapshot: the newest one wins, because a ref is only ever + * resolved against the snapshot that is current. `snapshot_id` is the generation the far-side + * computer stamped on it, so a ref carrying an older generation does not resolve to whatever now + * holds that ref — the staleness a persisted cache is rightly warned about is answered by matching + * the generation, not by keeping the cache in memory. + * + * Kept small on purpose: only the interactive elements a policy can match on, keyed by ref. It is a + * resolution table for the boundary, not a history of pages, and the next snapshot replaces it. + */ +export const computerSnapshot = pgTable("computer_snapshot", { + /** The computer these refs belong to. One live snapshot each, so it is the key. */ + computerId: text("computer_id").primaryKey(), + /** The generation the computer stamped on this snapshot. A ref names the one it came from. */ + snapshotId: integer("snapshot_id").notNull(), + /** The page it was taken on, so a rule about the host still has a page to match after a handover. */ + url: text("url").notNull(), + /** The interactive elements, keyed by ref. The one thing resolve looks a ref up in. */ + elements: jsonb("elements").notNull(), + takenAt: timestamp("taken_at", { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/server/src/index.ts b/server/src/index.ts index 38e062f..580857b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -28,6 +28,7 @@ import { createComputerProvider, describeComputerIsolation, } from "./computer/provider"; +import { createSnapshotStore } from "./computer/snapshot-store"; import { loadConfig } from "./config"; import { createConnectorAdminService } from "./connectors"; import { @@ -203,6 +204,10 @@ const computerGateway = computerProvider provider: computerProvider, auditStore: bootAuditStore, policy: () => policyStore.get(), + // In Postgres, so the ref a click carries resolves against the snapshot that produced it even + // when the snapshot was taken by another server. A Map here would be blank on every replica + // but the one that snapshotted, and the boundary would decide with no element to look at. + snapshots: createSnapshotStore(database), allowPrivateHosts: config.computer?.allowPrivateHosts, token: config.computer?.token, }) diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index 09a5782..af6baff 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -6,11 +6,15 @@ import { WorkspaceRefusedError, } from "../src/computer/gateway"; import type { ActionPolicy } from "../src/computer/policy"; -import type { SnapshotResult } from "../src/computer/schema"; import type { ComputerLocation, ComputerProvider, } from "../src/computer/provider"; +import type { SnapshotResult } from "../src/computer/schema"; +import { + createInMemorySnapshotStore, + type SnapshotStore, +} from "../src/computer/snapshot-store"; /** * What the gateway must guarantee, tested as properties rather than as call sequences. @@ -775,3 +779,109 @@ describe("resolving a computer's address", () => { await expect(gateway.locate("bot-1")).resolves.toContain("http"); }); }); + +/** + * The snapshot a ref resolves against is shared between servers, not held in one process. + * + * OpenBot runs several server processes behind a load balancer, and the process that answers a + * snapshot is rarely the one that answers the click that uses its refs. If the mapping from ref to + * element lives in a `Map`, it is missing on every replica but the one that snapshotted: the policy + * decides with no element in front of it, and the deny rule the deployment is relying on does not + * fire. These prove the resolution survives the hop to another replica, which is the whole point of + * moving it to a store. + * + * Two gateways sharing one store stand in for two replicas sharing one database. The store is the + * only thing they have in common, exactly as Postgres is the only thing two real replicas share. + */ +describe("resolving a ref across replicas", () => { + function replica(policy: ActionPolicy, snapshots: SnapshotStore) { + const { provider, fetchImpl, calls } = fakeComputer(); + const { store, rows } = fakeAudit(); + const gateway = createComputerGateway({ + provider, + fetchImpl, + auditStore: store, + policy: () => policy, + snapshots, + }); + return { gateway, calls, rows }; + } + + const DENY_SUBMIT: ActionPolicy = { + ...PERMISSIVE, + deny: ['contains(element.name, "submit")'], + }; + + test("a click is resolved and refused on a replica that never took the snapshot", async () => { + const snapshots = createInMemorySnapshotStore(); + const tookSnapshot = replica(DENY_SUBMIT, snapshots); + const handlesClick = replica(DENY_SUBMIT, snapshots); + + await tookSnapshot.gateway.snapshot("bot-1"); + + // e9 is the Submit button. The replica that never snapshotted still resolves it from the store and + // refuses it. Held in a Map, this gateway's snapshot would be empty, the rule would have no element + // to match, and the Submit click would go straight through: the boundary silently off on every + // server but the one that happened to snapshot. + await expect( + handlesClick.gateway.click("bot-1", ACTOR, { + ref: "e9", + snapshotId: 7, + }), + ).rejects.toThrow(ActionRefusedError); + expect(handlesClick.calls).toEqual([]); + expect(handlesClick.rows[0]?.eventType).toBe("computer.action_refused"); + }); + + test("the resolved element label comes from the store, so any replica can name it", async () => { + const snapshots = createInMemorySnapshotStore(); + const tookSnapshot = replica(PERMISSIVE, snapshots); + const handlesClick = replica(PERMISSIVE, snapshots); + + await tookSnapshot.gateway.snapshot("bot-1"); + const result = await handlesClick.gateway.type("bot-1", ACTOR, { + ref: "e1", + snapshotId: 7, + text: "Grace Hopper", + }); + + // The label is resolved on the replica that never saw the page, so the transcript and the trail + // say what was acted on instead of quoting a ref. + expect(result.element?.name).toBe("Customer name:"); + expect(handlesClick.rows[0]?.payload.element).toEqual({ + role: "input", + name: "Customer name:", + type: "text", + }); + }); + + test("a ref only resolves against the generation it came from", async () => { + // The store holds snapshot 7, where e9 is Submit. The same ref cited against snapshot 7 resolves; + // cited against an older snapshot 6 it does not, because the caller is looking at a page that has + // since moved on and its e9 may be a different control now. Resolving it anyway is the "decide on + // fiction" a persisted snapshot is rightly warned about; matching the generation is the answer. + // The computer makes the same check when the action reaches it and refuses a superseded ref there. + const snapshots = createInMemorySnapshotStore(); + const tookSnapshot = replica(PERMISSIVE, snapshots); + const handlesClick = replica(PERMISSIVE, snapshots); + + await tookSnapshot.gateway.snapshot("bot-1"); + + const current = await handlesClick.gateway.click("bot-1", ACTOR, { + ref: "e9", + snapshotId: 7, + }); + expect(current.element?.name).toBe("Submit order"); + + const superseded = await handlesClick.gateway.click("bot-1", ACTOR, { + ref: "e9", + snapshotId: 6, + }); + // Not resolved to snapshot 7's Submit button: the ref carried an older generation. + expect(superseded.element).toBeUndefined(); + expect(handlesClick.rows[1]?.payload.element).toBe( + "not in the current snapshot", + ); + }); + +}); diff --git a/server/tests/computer-snapshot-store.integration.test.ts b/server/tests/computer-snapshot-store.integration.test.ts new file mode 100644 index 0000000..0ebfec0 --- /dev/null +++ b/server/tests/computer-snapshot-store.integration.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { SnapshotElement } from "../src/computer/schema"; +import { + createSnapshotStore, + type StoredSnapshot, +} from "../src/computer/snapshot-store"; +import { createDatabase } from "../src/db/client"; +import { computerSnapshot } from "../src/db/schema"; +import { TEST_POOL } from "./support/database"; + +/** + * The snapshot a ref resolves against has to cross to another server. + * + * The gateway turns the opaque ref in a click into the element it points at by looking it up in the + * snapshot this deployment took. OpenBot runs several processes behind a load balancer, and the one + * that takes the snapshot is rarely the one that answers the click, so that mapping cannot live in a + * `Map` in a process: on every other replica it would be empty, the policy would decide with no + * element, and the boundary would be silently off. The row is the contract that carries the mapping + * from the server that snapshotted to the server that acts. + * + * Another replica is simulated by building a second store on the same database, the way the policy + * durability test simulates a restart. Reading back through the same store would only prove it + * remembers what it was told a moment ago, which is not the property in question. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +function snapshot( + snapshotId: number, + elements: SnapshotElement[], + url = "https://example.com/order", +): StoredSnapshot { + return { + snapshotId, + url, + elements: new Map(elements.map((element) => [element.ref, element])), + }; +} + +afterEach(async () => { + await database.delete(computerSnapshot); +}); + +describe("a snapshot taken on one server", () => { + test("is resolvable on another, elements and generation intact", async () => { + const tookSnapshot = createSnapshotStore(database); + await tookSnapshot.save( + "default", + snapshot(7, [ + { ref: "e1", role: "input", name: "Customer name:", type: "text" }, + { ref: "e9", role: "button", name: "Submit order" }, + ]), + ); + + // A different process would hold none of that in memory. The second store reads it from Postgres. + const handlesClick = createSnapshotStore(database); + const loaded = await handlesClick.load("default"); + expect(loaded?.snapshotId).toBe(7); + expect(loaded?.url).toBe("https://example.com/order"); + expect(loaded?.elements.get("e9")?.name).toBe("Submit order"); + // The type survives the JSON round trip, because a rule may care that a value went into a + // password field rather than a text one. + expect(loaded?.elements.get("e1")?.type).toBe("text"); + }); + + test("a computer that was never snapshotted loads as undefined", async () => { + const store = createSnapshotStore(database); + expect(await store.load("never-snapshotted")).toBeUndefined(); + }); + + test("a newer snapshot supersedes the last, keeping one row per computer", async () => { + const first = createSnapshotStore(database); + await first.save( + "default", + snapshot(7, [{ ref: "e9", role: "button", name: "Submit order" }]), + ); + + const second = createSnapshotStore(database); + await second.save( + "default", + snapshot(8, [{ ref: "e9", role: "button", name: "Cancel" }]), + ); + + const rows = await database.select().from(computerSnapshot); + // One live snapshot per computer, by construction: a ref is only ever resolved against the current + // page, so a second row would be a page history the boundary has no use for. + expect(rows).toHaveLength(1); + + const loaded = await createSnapshotStore(database).load("default"); + expect(loaded?.snapshotId).toBe(8); + expect(loaded?.elements.get("e9")?.name).toBe("Cancel"); + }); + + test("two computers keep their snapshots apart", async () => { + const store = createSnapshotStore(database); + await store.save( + "sales-bot", + snapshot(3, [{ ref: "e1", role: "button", name: "Send" }]), + ); + await store.save( + "research-bot", + snapshot(4, [{ ref: "e1", role: "link", name: "Open" }]), + ); + + const other = createSnapshotStore(database); + expect((await other.load("sales-bot"))?.elements.get("e1")?.name).toBe( + "Send", + ); + expect((await other.load("research-bot"))?.elements.get("e1")?.name).toBe( + "Open", + ); + }); +}); diff --git a/server/tests/computer-snapshot-store.test.ts b/server/tests/computer-snapshot-store.test.ts new file mode 100644 index 0000000..0e7cc6f --- /dev/null +++ b/server/tests/computer-snapshot-store.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import type { SnapshotElement } from "../src/computer/schema"; +import { + createInMemorySnapshotStore, + type StoredSnapshot, +} from "../src/computer/snapshot-store"; + +/** + * The in-memory store's own behaviour, without a database. + * + * The database-backed store has to agree with this one within a single process; the difference + * between them only shows with a second process, which is what the integration test is for. Here the + * contract is the plain one: what was saved is what loads, the newest snapshot per computer wins, and + * an unsnapshotted computer resolves to nothing rather than to a stale row. + */ + +function snapshot( + snapshotId: number, + elements: SnapshotElement[], + url = "https://example.com/order", +): StoredSnapshot { + return { + snapshotId, + url, + elements: new Map(elements.map((element) => [element.ref, element])), + }; +} + +describe("the in-memory snapshot store", () => { + test("loads back the snapshot it saved, elements and all", async () => { + const store = createInMemorySnapshotStore(); + await store.save( + "default", + snapshot(7, [ + { ref: "e1", role: "input", name: "Customer name:", type: "text" }, + { ref: "e9", role: "button", name: "Submit order" }, + ]), + ); + + const loaded = await store.load("default"); + expect(loaded?.snapshotId).toBe(7); + expect(loaded?.url).toBe("https://example.com/order"); + expect(loaded?.elements.get("e9")?.name).toBe("Submit order"); + expect(loaded?.elements.get("e1")?.type).toBe("text"); + }); + + test("a computer that was never snapshotted loads as undefined", async () => { + const store = createInMemorySnapshotStore(); + expect(await store.load("default")).toBeUndefined(); + }); + + test("the newest snapshot replaces the last, so a ref resolves against the current page", async () => { + const store = createInMemorySnapshotStore(); + await store.save( + "default", + snapshot(7, [{ ref: "e9", role: "button", name: "Submit order" }]), + ); + await store.save( + "default", + snapshot(8, [{ ref: "e9", role: "button", name: "Cancel" }]), + ); + + const loaded = await store.load("default"); + // The generation moves forward and the same ref now names a different control. Keeping the older + // snapshot would be exactly the stale mapping the gateway must never resolve against. + expect(loaded?.snapshotId).toBe(8); + expect(loaded?.elements.get("e9")?.name).toBe("Cancel"); + }); + + test("each computer keeps its own snapshot", async () => { + const store = createInMemorySnapshotStore(); + await store.save( + "sales-bot", + snapshot(3, [{ ref: "e1", role: "button", name: "Send" }]), + ); + await store.save( + "research-bot", + snapshot(4, [{ ref: "e1", role: "link", name: "Open" }]), + ); + + expect((await store.load("sales-bot"))?.elements.get("e1")?.name).toBe( + "Send", + ); + expect((await store.load("research-bot"))?.elements.get("e1")?.name).toBe( + "Open", + ); + }); +}); From f0e17856a203a8d739c40c70c512b626a664b2d3 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 07:45:42 -0700 Subject: [PATCH 2/2] Keep the newest snapshot when two replicas take one at once Two things the cross-replica move needs that a single process did not. The upsert was last-write-wins. Two replicas snapshotting the same computer is the case this table exists for, and Postgres has no say in which of their writes arrives second, so the older generation could overwrite the newer one and every ref the model was holding would stop resolving. The generation decides instead: it is stamped by the computer and increases by one per snapshot, so a lower number is an older page. Clocks cannot decide it, because there are two of them. Wiping a computer now clears the row. A fresh computer counts generations from one again, so a row left behind lets a ref from the previous session match the new session's first snapshot, and the policy decides against an element from a page that is gone. The generation only tells snapshots apart within a session, so the row has to go when the session does. Tests for both, and for clear on each store. The ordering one fails without the guard. --- server/drizzle/meta/0005_snapshot.json | 343 +++++------------- server/drizzle/meta/_journal.json | 2 +- server/src/computer/snapshot-store.ts | 9 +- server/tests/computer-gateway.test.ts | 17 + ...omputer-snapshot-store.integration.test.ts | 36 ++ server/tests/computer-snapshot-store.test.ts | 33 ++ 6 files changed, 176 insertions(+), 264 deletions(-) diff --git a/server/drizzle/meta/0005_snapshot.json b/server/drizzle/meta/0005_snapshot.json index ceb5c38..e44bb0e 100644 --- a/server/drizzle/meta/0005_snapshot.json +++ b/server/drizzle/meta/0005_snapshot.json @@ -123,12 +123,8 @@ "name": "accounts_user_id_users_id_fk", "tableFrom": "accounts", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -201,12 +197,8 @@ "name": "agents_package_id_deployment_packages_id_fk", "tableFrom": "agents", "tableTo": "deployment_packages", - "columnsFrom": [ - "package_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["package_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -320,12 +312,8 @@ "name": "channel_agents_channel_id_channels_id_fk", "tableFrom": "channel_agents", "tableTo": "channels", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -333,12 +321,8 @@ "name": "channel_agents_agent_id_agents_id_fk", "tableFrom": "channel_agents", "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -346,10 +330,7 @@ "compositePrimaryKeys": { "channel_agents_channel_id_agent_id_pk": { "name": "channel_agents_channel_id_agent_id_pk", - "columns": [ - "channel_id", - "agent_id" - ] + "columns": ["channel_id", "agent_id"] } }, "uniqueConstraints": {}, @@ -387,12 +368,8 @@ "name": "channel_memberships_channel_id_channels_id_fk", "tableFrom": "channel_memberships", "tableTo": "channels", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -400,12 +377,8 @@ "name": "channel_memberships_user_id_users_id_fk", "tableFrom": "channel_memberships", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -413,10 +386,7 @@ "compositePrimaryKeys": { "channel_memberships_channel_id_user_id_pk": { "name": "channel_memberships_channel_id_user_id_pk", - "columns": [ - "channel_id", - "user_id" - ] + "columns": ["channel_id", "user_id"] } }, "uniqueConstraints": {}, @@ -527,12 +497,8 @@ "name": "channels_package_id_deployment_packages_id_fk", "tableFrom": "channels", "tableTo": "deployment_packages", - "columnsFrom": [ - "package_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["package_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -540,12 +506,8 @@ "name": "channels_last_message_agent_id_agents_id_fk", "tableFrom": "channels", "tableTo": "agents", - "columnsFrom": [ - "last_message_agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -642,12 +604,8 @@ "name": "chunks_document_id_documents_id_fk", "tableFrom": "chunks", "tableTo": "documents", - "columnsFrom": [ - "document_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["document_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -688,12 +646,8 @@ "name": "connector_cursors_connector_instance_id_connector_instances_id_fk", "tableFrom": "connector_cursors", "tableTo": "connector_instances", - "columnsFrom": [ - "connector_instance_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -763,12 +717,8 @@ "name": "connector_instances_credential_id_credentials_id_fk", "tableFrom": "connector_instances", "tableTo": "credentials", - "columnsFrom": [ - "credential_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -894,9 +844,7 @@ "deployment_packages_tenant_id_unique": { "name": "deployment_packages_tenant_id_unique", "nullsNotDistinct": false, - "columns": [ - "tenant_id" - ] + "columns": ["tenant_id"] } }, "policies": {}, @@ -990,12 +938,8 @@ "name": "document_acls_document_id_documents_id_fk", "tableFrom": "document_acls", "tableTo": "documents", - "columnsFrom": [ - "document_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["document_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1123,12 +1067,8 @@ "name": "documents_connector_instance_id_connector_instances_id_fk", "tableFrom": "documents", "tableTo": "connector_instances", - "columnsFrom": [ - "connector_instance_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1198,12 +1138,8 @@ "name": "intelligence_channel_mappings_user_id_users_id_fk", "tableFrom": "intelligence_channel_mappings", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1211,12 +1147,8 @@ "name": "intelligence_channel_mappings_channel_id_channels_id_fk", "tableFrom": "intelligence_channel_mappings", "tableTo": "channels", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1224,10 +1156,7 @@ "compositePrimaryKeys": { "intelligence_channel_mappings_user_id_channel_id_pk": { "name": "intelligence_channel_mappings_user_id_channel_id_pk", - "columns": [ - "user_id", - "channel_id" - ] + "columns": ["user_id", "channel_id"] } }, "uniqueConstraints": {}, @@ -1328,12 +1257,8 @@ "name": "sessions_user_id_users_id_fk", "tableFrom": "sessions", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1343,9 +1268,7 @@ "sessions_token_unique": { "name": "sessions_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -1411,12 +1334,8 @@ "name": "sso_providers_user_id_users_id_fk", "tableFrom": "sso_providers", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1426,9 +1345,7 @@ "sso_providers_provider_id_unique": { "name": "sso_providers_provider_id_unique", "nullsNotDistinct": false, - "columns": [ - "provider_id" - ] + "columns": ["provider_id"] } }, "policies": {}, @@ -1513,12 +1430,8 @@ "name": "sync_runs_connector_instance_id_connector_instances_id_fk", "tableFrom": "sync_runs", "tableTo": "connector_instances", - "columnsFrom": [ - "connector_instance_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1560,12 +1473,8 @@ "name": "user_roles_user_id_users_id_fk", "tableFrom": "user_roles", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1573,10 +1482,7 @@ "compositePrimaryKeys": { "user_roles_user_id_role_pk": { "name": "user_roles_user_id_role_pk", - "columns": [ - "user_id", - "role" - ] + "columns": ["user_id", "role"] } }, "uniqueConstraints": {}, @@ -1648,9 +1554,7 @@ "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -1751,12 +1655,8 @@ "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk", "tableFrom": "webhook_subscriptions", "tableTo": "connector_instances", - "columnsFrom": [ - "connector_instance_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1890,12 +1790,8 @@ "name": "agent_preferences_user_id_users_id_fk", "tableFrom": "agent_preferences", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1903,12 +1799,8 @@ "name": "agent_preferences_agent_id_agents_id_fk", "tableFrom": "agent_preferences", "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1916,10 +1808,7 @@ "compositePrimaryKeys": { "agent_preferences_user_id_agent_id_pk": { "name": "agent_preferences_user_id_agent_id_pk", - "columns": [ - "user_id", - "agent_id" - ] + "columns": ["user_id", "agent_id"] } }, "uniqueConstraints": {}, @@ -2029,12 +1918,8 @@ "name": "agent_profiles_agent_id_agents_id_fk", "tableFrom": "agent_profiles", "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2042,12 +1927,8 @@ "name": "agent_profiles_owner_user_id_users_id_fk", "tableFrom": "agent_profiles", "tableTo": "users", - "columnsFrom": [ - "owner_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -2101,12 +1982,8 @@ "name": "component_exclusions_component_name_components_name_fk", "tableFrom": "component_exclusions", "tableTo": "components", - "columnsFrom": [ - "component_name" - ], - "columnsTo": [ - "name" - ], + "columnsFrom": ["component_name"], + "columnsTo": ["name"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2114,12 +1991,8 @@ "name": "component_exclusions_agent_id_agents_id_fk", "tableFrom": "component_exclusions", "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2127,10 +2000,7 @@ "compositePrimaryKeys": { "component_exclusions_component_name_agent_id_pk": { "name": "component_exclusions_component_name_agent_id_pk", - "columns": [ - "component_name", - "agent_id" - ] + "columns": ["component_name", "agent_id"] } }, "uniqueConstraints": {}, @@ -2181,12 +2051,8 @@ "name": "component_functions_component_name_components_name_fk", "tableFrom": "component_functions", "tableTo": "components", - "columnsFrom": [ - "component_name" - ], - "columnsTo": [ - "name" - ], + "columnsFrom": ["component_name"], + "columnsTo": ["name"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2194,10 +2060,7 @@ "compositePrimaryKeys": { "component_functions_component_name_function_name_pk": { "name": "component_functions_component_name_function_name_pk", - "columns": [ - "component_name", - "function_name" - ] + "columns": ["component_name", "function_name"] } }, "uniqueConstraints": {}, @@ -2407,12 +2270,8 @@ "name": "mcp_tools_server_id_mcp_servers_id_fk", "tableFrom": "mcp_tools", "tableTo": "mcp_servers", - "columnsFrom": [ - "server_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["server_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2420,10 +2279,7 @@ "compositePrimaryKeys": { "mcp_tools_server_id_name_pk": { "name": "mcp_tools_server_id_name_pk", - "columns": [ - "server_id", - "name" - ] + "columns": ["server_id", "name"] } }, "uniqueConstraints": {}, @@ -2496,12 +2352,8 @@ "name": "plugin_grants_agent_id_agents_id_fk", "tableFrom": "plugin_grants", "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2509,11 +2361,7 @@ "compositePrimaryKeys": { "plugin_grants_kind_ref_agent_id_pk": { "name": "plugin_grants_kind_ref_agent_id_pk", - "columns": [ - "kind", - "ref", - "agent_id" - ] + "columns": ["kind", "ref", "agent_id"] } }, "uniqueConstraints": {}, @@ -2763,12 +2611,8 @@ "name": "skills_owner_user_id_users_id_fk", "tableFrom": "skills", "tableTo": "users", - "columnsFrom": [ - "owner_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2784,62 +2628,37 @@ "public.acl_effect": { "name": "acl_effect", "schema": "public", - "values": [ - "allow", - "deny" - ] + "values": ["allow", "deny"] }, "public.agent_type": { "name": "agent_type", "schema": "public", - "values": [ - "built_in", - "remote_ag_ui" - ] + "values": ["built_in", "remote_ag_ui"] }, "public.connector_type": { "name": "connector_type", "schema": "public", - "values": [ - "google_drive", - "onedrive" - ] + "values": ["google_drive", "onedrive"] }, "public.credential_kind": { "name": "credential_kind", "schema": "public", - "values": [ - "model", - "connector", - "agent", - "mcp" - ] + "values": ["model", "connector", "agent", "mcp"] }, "public.role": { "name": "role", "schema": "public", - "values": [ - "admin", - "user" - ] + "values": ["admin", "user"] }, "public.sync_status": { "name": "sync_status", "schema": "public", - "values": [ - "pending", - "running", - "succeeded", - "failed" - ] + "values": ["pending", "running", "succeeded", "failed"] }, "public.agent_visibility": { "name": "agent_visibility", "schema": "public", - "values": [ - "public", - "private" - ] + "values": ["public", "private"] } }, "schemas": {}, @@ -2852,4 +2671,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index dcb5e64..3f06f8b 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -45,4 +45,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/server/src/computer/snapshot-store.ts b/server/src/computer/snapshot-store.ts index 7c84549..157638f 100644 --- a/server/src/computer/snapshot-store.ts +++ b/server/src/computer/snapshot-store.ts @@ -27,7 +27,7 @@ * Without a database it stays in memory. Tests that only exercise decision logic do not need * Postgres, exactly as the policy store does not. */ -import { eq } from "drizzle-orm"; +import { eq, lt } from "drizzle-orm"; import type { Database } from "../db/client"; import { computerSnapshot } from "../db/schema"; import type { SnapshotElement } from "./schema"; @@ -100,6 +100,13 @@ export function createSnapshotStore(database?: Database): SnapshotStore { elements: values.elements, takenAt: values.takenAt, }, + // Only ever forward. The generation is stamped by the computer and increases by one per + // snapshot, so a lower number is an older page. Two replicas snapshotting the same computer + // at once is the case this exists for: without the guard, whichever write reached Postgres + // last would win, so the older snapshot could overwrite the newer one and every ref the + // model is holding would stop resolving. Timestamps cannot decide it, because they come + // from two machines' clocks; the generation comes from one. + setWhere: lt(computerSnapshot.snapshotId, values.snapshotId), }); }, diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index af6baff..d6d54ad 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -884,4 +884,21 @@ describe("resolving a ref across replicas", () => { ); }); + test("wiping a computer forgets the snapshot, so a reused generation cannot resolve", async () => { + // A fresh computer counts generations from one again. If a reset left the row behind, a ref the + // model was still holding from the previous session would match the new session's first + // generation and the policy would decide against an element from a page that no longer exists. + const snapshots = createInMemorySnapshotStore(); + const tookSnapshot = replica(PERMISSIVE, snapshots); + const handlesClick = replica(PERMISSIVE, snapshots); + + await tookSnapshot.gateway.snapshot("bot-1"); + await tookSnapshot.gateway.resetComputer("bot-1", ACTOR); + + const afterReset = await handlesClick.gateway.click("bot-1", ACTOR, { + ref: "e9", + snapshotId: 7, + }); + expect(afterReset.element).toBeUndefined(); + }); }); diff --git a/server/tests/computer-snapshot-store.integration.test.ts b/server/tests/computer-snapshot-store.integration.test.ts index 0ebfec0..454a4f6 100644 --- a/server/tests/computer-snapshot-store.integration.test.ts +++ b/server/tests/computer-snapshot-store.integration.test.ts @@ -95,6 +95,42 @@ describe("a snapshot taken on one server", () => { expect(loaded?.elements.get("e9")?.name).toBe("Cancel"); }); + test("an older snapshot arriving late does not overwrite the newer one", async () => { + // Two replicas snapshotting the same computer at once. Generation 8 is written first and + // generation 7 arrives after it, which is the ordering a load balancer can produce and Postgres + // has no way to prevent. Last-write-wins would put the older page back and every ref the model is + // holding would stop resolving; the generation decides instead, because it comes from the + // computer rather than from two machines' clocks. + const ahead = createSnapshotStore(database); + await ahead.save( + "default", + snapshot(8, [{ ref: "e9", role: "button", name: "Cancel" }]), + ); + + const behind = createSnapshotStore(database); + await behind.save( + "default", + snapshot(7, [{ ref: "e9", role: "button", name: "Submit order" }]), + ); + + const loaded = await createSnapshotStore(database).load("default"); + expect(loaded?.snapshotId).toBe(8); + expect(loaded?.elements.get("e9")?.name).toBe("Cancel"); + }); + + test("clearing removes the row, so a wiped computer resolves nothing", async () => { + const store = createSnapshotStore(database); + await store.save( + "default", + snapshot(7, [{ ref: "e9", role: "button", name: "Submit order" }]), + ); + + await store.clear("default"); + + expect(await database.select().from(computerSnapshot)).toHaveLength(0); + expect(await createSnapshotStore(database).load("default")).toBeUndefined(); + }); + test("two computers keep their snapshots apart", async () => { const store = createSnapshotStore(database); await store.save( diff --git a/server/tests/computer-snapshot-store.test.ts b/server/tests/computer-snapshot-store.test.ts index 0e7cc6f..c0fedd2 100644 --- a/server/tests/computer-snapshot-store.test.ts +++ b/server/tests/computer-snapshot-store.test.ts @@ -67,6 +67,39 @@ describe("the in-memory snapshot store", () => { expect(loaded?.elements.get("e9")?.name).toBe("Cancel"); }); + test("clearing forgets the snapshot, so nothing resolves against a wiped computer", async () => { + const store = createInMemorySnapshotStore(); + await store.save( + "default", + snapshot(7, [{ ref: "e9", role: "button", name: "Submit order" }]), + ); + + await store.clear("default"); + + // A wiped computer starts counting generations from one again, so a row left behind would let a + // ref from the previous session match the new one and resolve to a page that is gone. + expect(await store.load("default")).toBeUndefined(); + }); + + test("clearing one computer leaves the others alone", async () => { + const store = createInMemorySnapshotStore(); + await store.save( + "sales-bot", + snapshot(3, [{ ref: "e1", role: "button", name: "Send" }]), + ); + await store.save( + "research-bot", + snapshot(4, [{ ref: "e1", role: "link", name: "Open" }]), + ); + + await store.clear("sales-bot"); + + expect(await store.load("sales-bot")).toBeUndefined(); + expect((await store.load("research-bot"))?.elements.get("e1")?.name).toBe( + "Open", + ); + }); + test("each computer keeps its own snapshot", async () => { const store = createInMemorySnapshotStore(); await store.save(