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..e44bb0e --- /dev/null +++ b/server/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2674 @@ +{ + "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": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 86ff22e..3f06f8b 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 } ] } 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..157638f --- /dev/null +++ b/server/src/computer/snapshot-store.ts @@ -0,0 +1,169 @@ +/** + * 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, lt } 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, + }, + // 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), + }); + }, + + 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..d6d54ad 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,126 @@ 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", + ); + }); + + 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 new file mode 100644 index 0000000..454a4f6 --- /dev/null +++ b/server/tests/computer-snapshot-store.integration.test.ts @@ -0,0 +1,153 @@ +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("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( + "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..c0fedd2 --- /dev/null +++ b/server/tests/computer-snapshot-store.test.ts @@ -0,0 +1,121 @@ +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("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( + "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", + ); + }); +});