diff --git a/server/drizzle/0001_computer_snapshot.sql b/server/drizzle/0001_computer_snapshot.sql new file mode 100644 index 0000000..d86da55 --- /dev/null +++ b/server/drizzle/0001_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/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..d307493 --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,2722 @@ +{ + "id": "f394b5c0-cdf7-4157-9767-17b78d204666", + "prevId": "084d702b-09fd-44df-a60a-22477be02359", + "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 + }, + "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.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.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 + }, + "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 540bea2..f92b6e4 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1786891733850, "tag": "0000_schema", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1787223440313, + "tag": "0001_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 b2337be..feeed15 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -38,6 +38,11 @@ import type { TypeInput, WriteFileInput, } 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. */ @@ -76,25 +81,30 @@ export type ComputerGatewayOptions = { auditStore: AuditStore; /** Absent denies everything. See evaluateActionPolicy. */ policy: () => ActionPolicy | undefined; -}; - -/** - * 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; + /** + * 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 function createComputerGateway(options: ComputerGatewayOptions) { const { client, auditStore, supervisor } = options; - 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(); /** * The computer, addressed as the Bot that is asking. @@ -105,10 +115,19 @@ export function createComputerGateway(options: ComputerGatewayOptions) { */ const as = (botId: string) => client.forBot(botId); - /** Read-only, so it passes straight through. Nothing has changed and there is nothing to decide. */ + /** + * 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(computerId: string): Promise { const result = await as(computerId).snapshot(); - snapshots.set(computerId, { + await snapshots.save(computerId, { snapshotId: result.snapshotId, url: result.url, elements: new Map( @@ -123,18 +142,25 @@ export function createComputerGateway(options: ComputerGatewayOptions) { } /** - * 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( - computerId: string, + stored: StoredSnapshot | undefined, ref: string | undefined, + snapshotId: number | undefined, ): SnapshotElement | undefined { - if (!ref) return undefined; - return snapshots.get(computerId)?.elements.get(ref); + if (!ref || !stored || stored.snapshotId !== snapshotId) return undefined; + return stored.elements.get(ref); } /** @@ -151,6 +177,8 @@ export function createComputerGateway(options: ComputerGatewayOptions) { 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; @@ -159,13 +187,15 @@ export function createComputerGateway(options: ComputerGatewayOptions) { }, run: () => Promise, ): Promise { - const { ref, filePath } = subject; - const element = resolve(computerId, ref); - const cached = snapshots.get(computerId); + 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(computerId); + 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); @@ -471,7 +501,11 @@ export function createComputerGateway(options: ComputerGatewayOptions) { "computer_click", botId, actor, - { ref: input.ref, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + snapshotId: input.snapshotId, + ...(signal ? { signal } : {}), + }, () => as(botId).click(input, signal), ); }, @@ -488,7 +522,11 @@ export function createComputerGateway(options: ComputerGatewayOptions) { "computer_type", botId, actor, - { ref: input.ref, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + snapshotId: input.snapshotId, + ...(signal ? { signal } : {}), + }, () => as(botId).type(input, signal), ); }, @@ -507,7 +545,12 @@ export function createComputerGateway(options: ComputerGatewayOptions) { 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 } : {}), + }, () => as(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..d29c0d2 --- /dev/null +++ b/server/src/computer/snapshot-store.ts @@ -0,0 +1,143 @@ +/** + * 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; +}; + +/** + * 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, + }, + }); + }, + + 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), + }; +} + +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 3fc067a..e86d061 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,9 +12,9 @@ import { startChannelActivityListener, } from "./channels/events"; import { createChannelStore } from "./channels/routes"; +import { websocket as channelSocket } from "./channels/socket"; import { createStallGuard } from "./channels/stall-guard"; import { createThreadIdentity } from "./channels/thread-identity"; -import { websocket as channelSocket } from "./channels/socket"; import { createSandboxedStore } from "./components/sandboxed"; import { createComponentStore } from "./components/store"; import { createComputerClient } from "./computer/client"; @@ -23,6 +23,7 @@ import { createPolicyStore, DEFAULT_ACTION_POLICY, } from "./computer/policy-store"; +import { createSnapshotStore } from "./computer/snapshot-store"; import { createSupervisorClient } from "./computer/supervisor"; import { loadConfig } from "./config"; import { createConnectorAdminService } from "./connectors"; @@ -343,6 +344,10 @@ const app = createApp( // Read on every decision rather than captured once, so a rule an administrator adds while the // server is running applies to the very next action instead of after a restart. 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), // Stop, reset and the listing act on containers when there are containers to act on. ...(supervisor ? { supervisor } : {}), }) diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index eff68c0..bc0779b 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -7,6 +7,10 @@ import { } from "../src/computer/gateway"; import type { ActionPolicy } from "../src/computer/policy"; 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. @@ -397,3 +401,114 @@ describe("the computer gateway", () => { expect(rows[0]?.payload.element).toBe("not in the current snapshot"); }); }); + +/** + * 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 { client, calls } = fakeClient(); + const { store, rows } = fakeAudit(); + const gateway = createComputerGateway({ + client, + 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("default"); + + // 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("default", "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("default"); + const result = await handlesClick.gateway.type("default", "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("default"); + + const current = await handlesClick.gateway.click( + "default", + "bot-1", + ACTOR, + { + ref: "e9", + snapshotId: 7, + }, + ); + expect(current.element?.name).toBe("Submit order"); + + const superseded = await handlesClick.gateway.click( + "default", + "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", + ); + }); +});