From 6b5f4a575394e333bde123524732bb56072c96d5 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 19 Aug 2026 13:50:08 -0700 Subject: [PATCH 1/5] Announce a Bot that has stopped and is waiting for somebody A Bot that hits a login wall calls computer_request_help, the gateway writes the row, the control state flips, and nothing else happens. If the person is not on that Bot's screen at that moment, nothing tells them, and the Bot waits until its ten minutes run out. The same is true of a secret request. It is the one event in this product worth interrupting somebody for and it was the one event that announced itself nowhere. notifications.ts holds the rule and nothing else: a Bot blocked on you earns an interruption, a Bot merely working does not, and every kind states its answer in one table so a new kind has to argue with the ones already there rather than being added quietly somewhere else. It also flattens what the model said into one line, because a model asked for a sentence sends back three paragraphs and a fenced code block, and a notification is one line by definition. Delivery is the channel event hub's, on a topic of its own. In process would have been fewer moving parts and silently wrong: a deployment runs more than one server process, the browser holds its socket to whichever one answered the upgrade, and the handover is served by whichever one answered that request. Nothing arranges for those to be the same process, so an in-memory event reaches the person only by luck, which works on a laptop and stops working in production without saying so. The events on that socket are now tagged, so one connection carries both kinds and a tab left open across a deploy skips what it cannot read instead of guessing. The gateway raises it where it already writes the audit row, after the row rather than before it: the trail is the record and the notification is a message about it. It is raised and not awaited, because telling somebody must never be able to delay, or fail, the handover that lets them unblock the Bot. The cost is that a notification that cannot be delivered is gone rather than retried. Silencing one Bot is a column on agent_preferences beside hidden_at, which is the same kind of thing: an opinion one person holds about one Bot that changes nothing for anybody else. The server reads it before it raises, so the switch is not decoration. It silences the notification only; the Bot still asks, its screen still shows the prompt, and the trail still records the handover. --- server/drizzle/0001_clammy_crystal.sql | 1 + server/drizzle/meta/0001_snapshot.json | 2509 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/agents/profile-store.ts | 70 + server/src/agents/profile-types.ts | 8 + server/src/agents/routes.ts | 38 + server/src/channels/events.ts | 107 +- server/src/channels/routes.ts | 3 +- server/src/computer/gateway.ts | 31 +- server/src/db/schema/coworker.ts | 22 + server/src/index.ts | 60 +- server/src/notifications.ts | 184 ++ .../tests/channel-events.integration.test.ts | 124 +- server/tests/computer-gateway.test.ts | 76 + server/tests/notifications.test.ts | 134 + server/tests/schema.test.ts | 10 + 16 files changed, 3352 insertions(+), 32 deletions(-) create mode 100644 server/drizzle/0001_clammy_crystal.sql create mode 100644 server/drizzle/meta/0001_snapshot.json create mode 100644 server/src/notifications.ts create mode 100644 server/tests/notifications.test.ts diff --git a/server/drizzle/0001_clammy_crystal.sql b/server/drizzle/0001_clammy_crystal.sql new file mode 100644 index 0000000..3141326 --- /dev/null +++ b/server/drizzle/0001_clammy_crystal.sql @@ -0,0 +1 @@ +ALTER TABLE "agent_preferences" ADD COLUMN "notifications_muted_at" timestamp with time zone; \ No newline at end of file diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..75acd93 --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,2509 @@ +{ + "id": "f04250f3-7389-428f-a9ee-0612e7256187", + "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.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 + }, + "notifications_muted_at": { + "name": "notifications_muted_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": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 540bea2..161ec2a 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": 1787171166283, + "tag": "0001_clammy_crystal", + "breakpoints": true } ] } diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index 02c9755..819836b 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -46,6 +46,29 @@ export type AgentProfileStore = { ): Promise; duplicate(actor: AgentActor, id: string): Promise; setHidden(actor: AgentActor, id: string, hidden: boolean): Promise; + /** + * Silence, or unsilence, one Bot's notifications for one person. + * + * Beside `setHidden` rather than somewhere of its own because it is the same kind of thing: an + * opinion one person holds about one Bot, kept in the same row, changing nothing for anybody else. + */ + setNotificationsMuted( + actor: AgentActor, + id: string, + muted: boolean, + ): Promise; + /** + * Whether this person has silenced this Bot. + * + * Takes a user id rather than an actor, and does no access check, because the only caller is the + * notification path and it is about to address this person by that id. There is nothing to leak: + * an id that names nobody, or a Bot this person has never heard of, has no row and is not muted. + * + * False when the read fails, which is the deliberate choice. A preference store that is briefly + * unreachable should cost somebody an unwanted notification, not a Bot that waits all afternoon + * because nothing told them. + */ + notificationsMuted(userId: string, id: string): Promise; softDelete(actor: AgentActor, id: string): Promise; }; @@ -80,6 +103,7 @@ const joinedProjection = { ownerUserId: agentProfiles.ownerUserId, packageId: deploymentPackages.id, hiddenAt: agentPreferences.hiddenAt, + notificationsMutedAt: agentPreferences.notificationsMutedAt, deletedAt: agentProfiles.deletedAt, configuration: agents.configuration, }; @@ -123,6 +147,7 @@ function mapProfile( ownerUserId: row.ownerUserId, systemOwned: row.packageId !== null, hidden: row.hiddenAt !== null, + notificationsMuted: row.notificationsMutedAt !== null, deletedAt: row.deletedAt, endpoint: endpointOf(row.configuration), // Whether a key is set, never which. The form needs to show "a key is set" so a person does not @@ -387,6 +412,51 @@ export function createAgentProfileStore( }); }, + setNotificationsMuted(actor, id, muted) { + return database.transaction(async (transaction) => { + const profile = await findAccessibleProfile(transaction, actor, id); + if (!profile) throw new AgentNotFoundError(id); + + const notificationsMutedAt = muted ? new Date() : null; + await transaction + .insert(agentPreferences) + .values({ userId: actor.id, agentId: id, notificationsMutedAt }) + // This column alone. The row is shared with `hiddenAt`, and an upsert that wrote the whole + // row would unhide a Bot somebody had hidden, from a control that says nothing about + // hiding. + .onConflictDoUpdate({ + target: [agentPreferences.userId, agentPreferences.agentId], + set: { notificationsMutedAt }, + }); + }); + }, + + async notificationsMuted(userId, id) { + try { + const [row] = await database + .select({ mutedAt: agentPreferences.notificationsMutedAt }) + .from(agentPreferences) + .where( + and( + eq(agentPreferences.userId, userId), + eq(agentPreferences.agentId, id), + ), + ) + .limit(1); + return row?.mutedAt != null; + } catch (error) { + console.error( + JSON.stringify({ + type: "notification-preference-read-failed", + agent: id, + error: String(error), + note: "Read as not muted, so a Bot waiting on somebody is still announced.", + }), + ); + return false; + } + }, + softDelete(actor, id) { return database.transaction( async (transaction) => { diff --git a/server/src/agents/profile-types.ts b/server/src/agents/profile-types.ts index ad2e2f6..480ffb2 100644 --- a/server/src/agents/profile-types.ts +++ b/server/src/agents/profile-types.ts @@ -15,6 +15,14 @@ export type AgentProfile = { ownerUserId: string | null; systemOwned: boolean; hidden: boolean; + /** + * Whether this person has silenced this Bot's notifications. + * + * Per person, like `hidden`, so it is on the profile the way that is: what the roster shows is + * already whatever the person asking has decided about it, and a second shape for a second + * preference would be two answers to the same question. + */ + notificationsMuted: boolean; deletedAt: Date | null; /** Where this coworker runs. Null for the Bot in the box. */ endpoint: string | null; diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index 303ec8e..010f2a6 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -307,6 +307,43 @@ export function createAgentRoutes( } }); + /** + * Silence one Bot, and let it speak again. + * + * Two routes rather than one taking a boolean, matching hide and unhide above. A person pressing + * a switch is asking for a state, not for a toggle, and a toggle that arrives twice because the + * network was slow leaves them with the opposite of what they asked for. + */ + routes.post("/:agentId/notifications/mute", requireUser, async (context) => { + try { + await store.setNotificationsMuted( + context.var.actor, + context.req.param("agentId"), + true, + ); + return context.body(null, 204); + } catch (error) { + return mapStoreError(context, error); + } + }); + + routes.post( + "/:agentId/notifications/unmute", + requireUser, + async (context) => { + try { + await store.setNotificationsMuted( + context.var.actor, + context.req.param("agentId"), + false, + ); + return context.body(null, 204); + } catch (error) { + return mapStoreError(context, error); + } + }, + ); + routes.delete("/:agentId", requireUser, async (context) => { try { await store.softDelete(context.var.actor, context.req.param("agentId")); @@ -340,6 +377,7 @@ function agentDto(actor: AgentActor, agent: AgentProfile) { avatarSeed: agent.avatarSeed, visibility: agent.visibility, hidden: agent.hidden, + notificationsMuted: agent.notificationsMuted, systemOwned: agent.systemOwned, // Published so the edit form can show it. Safe to expose: it is an address the person supplied, // and any credential for it lives in the vault, never in this row. diff --git a/server/src/channels/events.ts b/server/src/channels/events.ts index 3bd6cd1..15a05e4 100644 --- a/server/src/channels/events.ts +++ b/server/src/channels/events.ts @@ -1,36 +1,81 @@ +import { sql } from "drizzle-orm"; import postgres from "postgres"; +import type { Database } from "../db/client"; +import type { Notification } from "../notifications"; /** - * Live channel activity, from whoever ran an agent to everybody else in the channel. + * Live events, from whichever server process produced them to whichever one the person is connected + * to. * - * The person who ran it already has the reply and reports it over HTTP; this is the other direction, - * telling the channel's other members that something was said. It is an optimisation and never a - * source of truth: the roster query stays authoritative, and a client that misses events while - * disconnected recovers by refetching on reconnect. Nothing may be knowable only through the socket. + * Two things travel this way. Channel activity tells a channel's other members that something was + * said; the person who ran the agent already has the reply and reports it over HTTP, so this is the + * other direction. Notifications tell one person that a Bot of theirs has stopped and is waiting on + * them. + * + * Neither is a source of truth. The roster query stays authoritative for activity, and a Bot that is + * waiting is still visibly waiting on its own screen; a client that misses events while disconnected + * recovers by refetching on reconnect. Nothing may be knowable only through the socket. * * Delivery goes through Postgres rather than an in-process list, because an in-process list is * silently wrong the moment a second server instance exists: the writer is on one and the listener - * on the other, and the message is never delivered. + * on the other, and the message is never delivered. That is not a hypothetical for a notification. + * A deployment runs more than one process, the browser holds its socket to whichever one answered + * the upgrade, and the handover that raises the notification is served by whichever one answered + * that request. Nothing arranges for those to be the same process, so an event raised in memory + * reaches the person only when they happen to have got lucky, which is worse than not having the + * feature: it works on a laptop and stops working in production, silently. + * + * One socket carries both, so the events are tagged. A second WebSocket would need its own upgrade, + * its own session guard, its own reconnect and its own backoff, all to move a payload the existing + * one is already open for. */ export const CHANNEL_ACTIVITY_TOPIC = "channel_activity"; +/** + * Notifications, on a topic of their own rather than folded into channel activity. + * + * Separate because the two are addressed differently. Activity goes to a channel's members, which + * the writer resolved from the membership table; a notification goes to one person, and the rule + * for who that is belongs to notifications.ts. Sharing a topic would mean every listener parsing + * every event to discover it was not for them. + */ +export const NOTIFICATION_TOPIC = "user_notification"; + export type ChannelActivityEvent = { + type: "channel.activity"; channelId: string; /** Who may receive it. Resolved by the writer, which already had to check membership. */ - memberIds: string[]; + recipientIds: string[]; lastMessage: string | null; lastMessageAt: string | null; lastMessageAgentId: string | null; }; +export type NotificationEvent = { + type: "notification"; + /** Whose Bot is waiting. One person, in practice; a list because the hub addresses everything that way. */ + recipientIds: string[]; + notification: Notification; +}; + +/** + * Anything the hub can fan out. + * + * `type` is on the wire rather than inferred from the shape, so a browser that receives an event it + * was not built to understand can ignore it by name instead of by guessing. A deployment can be + * mid-rollout with an older tab open, and an old tab treating a notification as channel activity + * would corrupt its roster rather than skip an event. + */ +export type LiveEvent = ChannelActivityEvent | NotificationEvent; + type Send = (payload: string) => void; export type ChannelEventHub = { /** Attach a connection for a person. Returns the detach. */ register(userId: string, send: Send): () => void; /** Fan one event out to this instance's own connections. */ - deliver(event: ChannelActivityEvent): void; + deliver(event: LiveEvent): void; connectionCount(userId: string): number; }; @@ -54,7 +99,7 @@ export function createChannelEventHub(): ChannelEventHub { }, deliver(event) { - for (const userId of event.memberIds) { + for (const userId of event.recipientIds) { for (const send of connections.get(userId) ?? []) { try { send(JSON.stringify(event)); @@ -72,28 +117,34 @@ export function createChannelEventHub(): ChannelEventHub { }; } -export type ChannelActivityListener = { stop: () => Promise }; +export type LiveEventListener = { stop: () => Promise }; /** - * Listen for activity announced by any instance, including this one. + * Listen for events announced by any instance, including this one. * * On its own connection, because `LISTEN` holds one for the life of the subscription: taken from the - * pool, it would be a connection the rest of the server never gets back. + * pool, it would be a connection the rest of the server never gets back. Both topics share that one + * connection for the same reason, a second subscription is a second connection held forever, and + * nothing about a notification needs its own. */ -export async function startChannelActivityListener( +export async function startLiveEventListener( databaseUrl: string, hub: ChannelEventHub, -): Promise { +): Promise { const connection = postgres(databaseUrl, { max: 1 }); - await connection.listen(CHANNEL_ACTIVITY_TOPIC, (payload) => { + const fanOut = (payload: string) => { try { - hub.deliver(JSON.parse(payload) as ChannelActivityEvent); + hub.deliver(JSON.parse(payload) as LiveEvent); } catch { // A payload we cannot read is not a reason to tear down the subscription: the roster query is - // still correct, and the next refetch shows whatever this event would have. + // still correct, the Bot is still visibly waiting on its own screen, and the next refetch + // shows whatever this event would have. } - }); + }; + + await connection.listen(CHANNEL_ACTIVITY_TOPIC, fanOut); + await connection.listen(NOTIFICATION_TOPIC, fanOut); return { stop: async () => { @@ -101,3 +152,23 @@ export async function startChannelActivityListener( }, }; } + +/** + * Announce a notification to whoever it is for, wherever they are connected. + * + * Takes an executor rather than a pool so a caller inside a transaction can announce on commit, the + * way the channel store does. Nothing raises a notification inside a transaction today, and the + * first thing that does should not have to move this function to do it. + * + * `NOTIFY` caps its payload at 8000 bytes. A notification is a couple of ids, a fixed headline and a + * detail that notifications.ts has already clipped, so the cap is not close; it is worth knowing + * about before somebody adds a field that carries a transcript. + */ +export async function announceNotification( + executor: Pick, + event: NotificationEvent, +): Promise { + await executor.execute( + sql`select pg_notify(${NOTIFICATION_TOPIC}, ${JSON.stringify(event)})`, + ); +} diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index 4789811..e8a2c85 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -323,8 +323,9 @@ export function createChannelStore( // back is never announced. The payload carries the members because the writer has already // resolved them; NOTIFY caps at 8000 bytes, which a 200-character preview leaves room in. const event: ChannelActivityEvent = { + type: "channel.activity", channelId, - memberIds: members.map((member) => member.userId), + recipientIds: members.map((member) => member.userId), lastMessage, lastMessageAt: activity.at.toISOString(), lastMessageAgentId: activity.agentId, diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index b2337be..e2f78e5 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -18,6 +18,7 @@ * The refs are opaque to the caller precisely so that the server holds the mapping. */ import { type AuditStore, recordAuditEvent } from "../audit"; +import type { NotificationRaiser } from "../notifications"; import type { ComputerClient } from "./client"; import { type ActionPolicy, @@ -76,6 +77,17 @@ export type ComputerGatewayOptions = { auditStore: AuditStore; /** Absent denies everything. See evaluateActionPolicy. */ policy: () => ActionPolicy | undefined; + /** + * Where "this Bot has stopped and is waiting for you" is announced. + * + * Optional, and absent leaves every handover working exactly as it did: the Bot still asks, the + * control state still flips, and the screen still shows the prompt. What is lost is the person + * being told when they are not looking at that screen, which is a courtesy and not a control. + * + * A courtesy is the reason this is raised rather than awaited. Telling somebody must never be + * able to delay, or fail, the handover that lets them unblock the Bot. + */ + notify?: NotificationRaiser; }; /** @@ -93,7 +105,7 @@ type CachedSnapshot = { }; export function createComputerGateway(options: ComputerGatewayOptions) { - const { client, auditStore, supervisor } = options; + const { client, auditStore, supervisor, notify } = options; const snapshots = new Map(); /** @@ -269,6 +281,14 @@ export function createComputerGateway(options: ComputerGatewayOptions) { computerId, reason, }); + // After the row, because the trail is the record and the notification is a message about it. + // A person told about something the trail does not contain would have nothing to look at. + notify?.({ + kind: "help_requested", + botId, + userId: actor.id, + detail: reason, + }); return state; }, @@ -409,6 +429,15 @@ export function createComputerGateway(options: ComputerGatewayOptions) { computerId, reason: `${input.label} (into ${input.ref})`, }); + // The label only. It is what the page asked for, which is the part that tells a person whether + // to get up; the ref means nothing away from the screen and the value is on another path + // entirely, and a notification is read in places an audit row is not. + notify?.({ + kind: "secret_requested", + botId, + userId: actor.id, + detail: input.label, + }); return state; }, diff --git a/server/src/db/schema/coworker.ts b/server/src/db/schema/coworker.ts index 8438f33..1f7fdb9 100644 --- a/server/src/db/schema/coworker.ts +++ b/server/src/db/schema/coworker.ts @@ -49,6 +49,14 @@ export const agentProfiles = pgTable( ], ); +/** + * What one person has decided about one Bot. + * + * Per person and per Bot, because both of the things kept here are opinions rather than facts about + * the Bot: hiding it from a roster changes nothing for anybody else, and neither does silencing it. + * A row exists only once somebody has said something, so the absence of a row is the default answer + * to every column, which is why every column is nullable. + */ export const agentPreferences = pgTable( "agent_preferences", { @@ -59,6 +67,20 @@ export const agentPreferences = pgTable( .notNull() .references(() => agents.id, { onDelete: "cascade" }), hiddenAt: timestamp("hidden_at", { withTimezone: true }), + /** + * When this person silenced this Bot's notifications, or null. + * + * A timestamp rather than a boolean, matching `hiddenAt`, because "when did this stop telling me + * anything" is the question somebody asks after a Bot has been quiet for a week and they cannot + * remember doing it. + * + * Silences the notification only. The Bot still asks for help, its screen still shows the + * prompt, and the audit trail still records the handover, because a preference about being + * interrupted must not be able to turn into a preference about being governed. + */ + notificationsMutedAt: timestamp("notifications_muted_at", { + withTimezone: true, + }), }, (table) => [primaryKey({ columns: [table.userId, table.agentId] })], ); diff --git a/server/src/index.ts b/server/src/index.ts index 2d72bca..fc61a48 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,8 +8,9 @@ import { DEV_ACTOR, initializeDevActorUser } from "./auth/dev-actor"; import { createRoleRepository } from "./auth/guards"; import type { OpenBotRole } from "./auth/roles"; import { + announceNotification, createChannelEventHub, - startChannelActivityListener, + startLiveEventListener, } from "./channels/events"; import { createChannelStore } from "./channels/routes"; import { createThreadIdentity } from "./channels/thread-identity"; @@ -36,6 +37,7 @@ import { resolveModelApiKey, } from "./credentials"; import { createDatabase } from "./db/client"; +import { type NotificationRaiser, notificationFor } from "./notifications"; import { createPluginStore } from "./plugins/store"; import { createPackageStatusReader, @@ -139,9 +141,9 @@ const channelEvents = createChannelEventHub(); * and owns only what may be done with it. */ const componentStore = createComponentStore(database); -// Its own connection is held for the life of the process; announced activity from any instance -// arrives here and is fanned out to connected members. -const channelActivityListener = await startChannelActivityListener( +// Its own connection is held for the life of the process; anything announced by any instance +// arrives here and is fanned out to whichever of this instance's connections it is addressed to. +const liveEventListener = await startLiveEventListener( config.databaseUrl, channelEvents, ); @@ -253,6 +255,49 @@ console.info( }), }), ); +/** + * A Bot that has stopped and is waiting for somebody, on its way to that somebody. + * + * Three parts that are deliberately three modules. `notifications.ts` decides whether this is worth + * an interruption and frames it, the profile store answers whether this person has silenced this + * Bot, and `announceNotification` puts it on the wire for whichever process the person is connected + * to. Composing them is this file's job because this file is where the deployment's parts meet, and + * because none of the three should have to know the other two exist. + * + * Nothing awaits this. A handover is what makes a blocked Bot recoverable, and it must not be able + * to fail, or to wait, because the database that holds a preference is slow. The consequence is + * accepted rather than papered over: a notification that cannot be delivered is gone, and the + * person finds out the way they did before, by looking. The audit row is written either way and is + * the record. + */ +const raiseNotification: NotificationRaiser = (blocked) => { + void (async () => { + const muted = await agentProfileStore.notificationsMuted( + blocked.userId, + blocked.botId, + ); + const notification = notificationFor(blocked, { muted }); + if (!notification) return; + await announceNotification(database, { + type: "notification", + recipientIds: [blocked.userId], + notification, + }); + })().catch((error: unknown) => { + // Said out loud. A courtesy that silently stops working is indistinguishable from a product that + // never had it, and the first report will be somebody saying their Bot "just hangs". + console.error( + JSON.stringify({ + type: "notification-not-raised", + bot: blocked.botId, + kind: blocked.kind, + error: error instanceof Error ? error.message : String(error), + note: "The handover itself was unaffected and is in the audit trail.", + }), + ); + }); +}; + /** * One Bot's endpoint must not take down the platform. * @@ -327,6 +372,9 @@ 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(), + // A Bot asking for help or for a secret is the one thing here worth interrupting somebody + // for. Without this it announces itself only on a screen nobody may be looking at. + notify: raiseNotification, // Stop, reset and the listing act on containers when there are containers to act on. ...(supervisor ? { supervisor } : {}), }) @@ -484,11 +532,11 @@ if (config.devNoAuth) { ); } -// The activity listener holds a connection of its own for the life of the process. Released on the +// The live event listener holds a connection of its own for the life of the process. Released on the // way out, so a watch-mode restart does not leave one behind on every reload. for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { - void channelActivityListener.stop().finally(() => process.exit(0)); + void liveEventListener.stop().finally(() => process.exit(0)); }); } diff --git a/server/src/notifications.ts b/server/src/notifications.ts new file mode 100644 index 0000000..2cefaa5 --- /dev/null +++ b/server/src/notifications.ts @@ -0,0 +1,184 @@ +/** + * The rule for what is worth interrupting somebody for. + * + * A Bot that is blocked on you is worth interrupting somebody for. A Bot that is merely working is + * not. That is the entire rule, and it is written down in one place so that a new notification has + * to argue with it rather than being added quietly somewhere else. + * + * Kept small on purpose, because the worth of a notification is relative to the others. A deployment + * that announces six things a person cannot act on has also stopped announcing the seventh, which + * they could have: the learned response to a notification from this product becomes to dismiss it + * without reading it. Blocked-on-a-person is the one class where the interruption buys something, + * because nothing further happens at all until it is answered. + * + * Delivery is deliberately somebody else's problem. This module decides WHETHER, and produces the + * frame; where that frame travels and which screens it reaches belongs to channels/events.ts. The + * two are apart because they change for different reasons: this changes when somebody argues a new + * kind of event into the product, and the transport changes when a deployment grows a second server + * process. + */ + +/** One kind of thing that could become a notification, and the product's own words for it. */ +type NotificationRule = { + /** + * Whether nothing further happens until a person acts. + * + * The whole decision, and not a hint. False is a legitimate answer for a kind that is worth + * naming in the vocabulary but not worth a person's attention, which is how a kind earns its way + * in without every kind earning an interruption. + */ + blocking: boolean; + /** + * What happened, said by the product rather than by the model. + * + * Fixed text, because this is the line a person reads while looking at something else, and a + * model that has just failed to log in is not the right author for the sentence that describes + * its own failure. What the model said goes in `detail`, where it is treated as words rather than + * as a claim about what the product wants. + */ + headline: string; +}; + +/** + * Every kind, and whether it earns an interruption. + * + * A table rather than a switch so that a kind being added lands beside the ones already here and + * has to state its `blocking` answer out loud, in the same place a reader can compare it against + * the others. Adding "a scheduled run failed" or "a Bot is asking to be allowed to do something" is + * an entry here and nothing else; the frame, the transport and the surface all follow from it. + */ +const KINDS = { + help_requested: { + blocking: true, + headline: "needs you at the keyboard", + }, + secret_requested: { + blocking: true, + headline: "is asking you for a value it must not be told", + }, +} as const satisfies Record; + +export type NotificationKind = keyof typeof KINDS; + +/** The vocabulary, for anything that has to validate a kind that arrived from outside. */ +export const notificationKinds = Object.keys(KINDS) as NotificationKind[]; + +/** + * How much of the Bot's own words survive into the notification. + * + * Short because the two places this lands are a toast beside somebody's work and the operating + * system's own notification, and both truncate whatever they are given without saying they have. + * Clipping here means the sentence ends with an ellipsis a person can recognise rather than in the + * middle of a word. + */ +export const NOTIFICATION_DETAIL_LIMIT = 140; + +/** What is handed over when a Bot becomes blocked. Facts only; the rule is applied here. */ +export type BlockedBot = { + kind: NotificationKind; + /** + * Which Bot is waiting. + * + * The one field a click cannot be routed without. A notification a person cannot act on from the + * notification itself is a worse interruption than none, because it costs the attention and then + * makes them go looking. + */ + botId: string; + /** + * Who it is for: the person whose session the Bot is acting under. + * + * Deliberately not everybody who can see this Bot. A login wall is one person's problem at one + * moment, and paging a whole deployment for it would teach everybody else to ignore the next one. + */ + userId: string; + /** The Bot's own words about what it needs. Flattened before it is shown; never trusted as markup. */ + detail: string; +}; + +/** A notification, framed and ready for whatever carries it. */ +export type Notification = { + /** Stable for one raising, so a surface can key a toast on it and not show the same one twice. */ + id: string; + kind: NotificationKind; + /** Which Bot is waiting, and therefore where a click goes. */ + botId: string; + headline: string; + detail: string; + /** ISO-8601. */ + at: string; +}; + +/** Whether this kind of event is one a person should be interrupted for. */ +export function isWorthInterrupting(kind: NotificationKind): boolean { + return KINDS[kind].blocking; +} + +/** + * Decide, and frame it if the answer is yes. + * + * Null is the ordinary answer, not an error: a kind that does not block, or a Bot this person has + * muted, produces nothing at all rather than a notification somebody downstream is trusted to throw + * away. One place decides, so a surface that renders whatever it is handed is correct by + * construction. + * + * The Bot's name is not here. The frame carries the id because the id is what routes the click, and + * the roster the browser already holds is where a name comes from; resolving one here would put a + * query on the path of a handover to buy a string the surface has anyway. + */ +export function notificationFor( + blocked: BlockedBot, + /** What this person has said about this Bot. See `agentPreferences`. */ + preference: { muted: boolean }, + now: Date = new Date(), +): Notification | null { + if (preference.muted) return null; + if (!isWorthInterrupting(blocked.kind)) return null; + + return { + id: crypto.randomUUID(), + kind: blocked.kind, + botId: blocked.botId, + headline: KINDS[blocked.kind].headline, + detail: summarize(blocked.detail), + at: now.toISOString(), + }; +} + +/** + * Flatten a model's answer into one line fit for a notification. + * + * A model asked for one sentence will sometimes send back three paragraphs and a fenced code block, + * and a notification is one line by definition. Fences are unwrapped rather than dropped along with + * what is inside them: a Bot that puts the whole of what it needs inside backticks would otherwise + * produce an empty notification, which is the one outcome worse than an untidy one. + * + * Control characters go too. This text is rendered in a toast and handed to the operating system's + * own notification, and a terminal escape somebody's page put in front of the model has no business + * following it into either. + */ +export function summarize( + text: string, + limit = NOTIFICATION_DETAIL_LIMIT, +): string { + const unfenced = text.replaceAll(/```[^\n`]*/g, " ").replaceAll("`", ""); + // biome-ignore lint/suspicious/noControlCharactersInRegex: removing them is the point. + const printable = unfenced.replaceAll(/[\u0000-\u001f\u007f-\u009f]+/g, " "); + const collapsed = printable.replaceAll(/\s+/g, " ").trim(); + + // Counted in code points rather than UTF-16 units, so a clip never lands between the halves of a + // surrogate pair and leaves a replacement character in front of somebody. + const codePoints = Array.from(collapsed); + if (codePoints.length <= limit) return collapsed; + return `${codePoints.slice(0, Math.max(limit - 1, 0)).join("")}…`; +} + +/** + * Raise one, and carry on. + * + * Returns nothing and is never awaited by its caller. A handover is the escape hatch that makes a + * blocked Bot recoverable at all, and it must not fail, or wait, because the thing that tells + * somebody about it is slow or broken. The cost is that a notification which cannot be delivered is + * lost rather than retried, which is the right trade for a courtesy: the audit row is the record, + * and the Bot is still visibly waiting on its own screen. + */ +export type NotificationRaiser = (blocked: BlockedBot) => void; diff --git a/server/tests/channel-events.integration.test.ts b/server/tests/channel-events.integration.test.ts index 98d9325..cc4f290 100644 --- a/server/tests/channel-events.integration.test.ts +++ b/server/tests/channel-events.integration.test.ts @@ -4,9 +4,11 @@ import { eq } from "drizzle-orm"; import { createAgentProfileStore } from "../src/agents/profile-store"; import type { AgentActor } from "../src/agents/profile-types"; import { + announceNotification, type ChannelActivityEvent, createChannelEventHub, - startChannelActivityListener, + type NotificationEvent, + startLiveEventListener, } from "../src/channels/events"; import { createChannelStore } from "../src/channels/routes"; import { createThreadIdentity } from "../src/channels/thread-identity"; @@ -22,8 +24,9 @@ import { function event(overrides: Partial = {}) { return { + type: "channel.activity", channelId: "channel_1", - memberIds: ["user-1"], + recipientIds: ["user-1"], lastMessage: "Said something.", lastMessageAt: "2026-08-15T10:00:00.000Z", lastMessageAgentId: null, @@ -31,6 +34,22 @@ function event(overrides: Partial = {}) { } satisfies ChannelActivityEvent; } +function notification(overrides: Partial = {}) { + return { + type: "notification", + recipientIds: ["user-1"], + notification: { + id: "notification-1", + kind: "help_requested", + botId: "risk-analyst", + headline: "needs you at the keyboard", + detail: "This page is asking for a code sent to your phone.", + at: "2026-08-15T10:00:00.000Z", + }, + ...overrides, + } satisfies NotificationEvent; +} + describe("channel event hub", () => { test("delivers only to the members of the channel", () => { const hub = createChannelEventHub(); @@ -39,7 +58,7 @@ describe("channel event hub", () => { hub.register("user-1", (payload) => member.push(payload)); hub.register("user-2", (payload) => stranger.push(payload)); - hub.deliver(event({ memberIds: ["user-1"] })); + hub.deliver(event({ recipientIds: ["user-1"] })); expect(member).toHaveLength(1); expect(JSON.parse(member[0] as string).lastMessage).toBe("Said something."); @@ -84,6 +103,54 @@ describe("channel event hub", () => { expect(() => hub.deliver(event())).not.toThrow(); expect(healthy).toHaveLength(1); }); + + /** + * A notification is addressed to one person, not to a channel's membership, and it rides the same + * socket. Both facts are load-bearing: the first is the whole rule about who gets interrupted, and + * the second is why the browser has to be able to tell one kind of event from the other. + */ + test("delivers a notification only to the person it names", () => { + const hub = createChannelEventHub(); + const waiting: string[] = []; + const somebodyElse: string[] = []; + hub.register("user-1", (payload) => waiting.push(payload)); + hub.register("user-2", (payload) => somebodyElse.push(payload)); + + hub.deliver(notification({ recipientIds: ["user-1"] })); + + expect(waiting).toHaveLength(1); + expect(somebodyElse).toEqual([]); + }); + + test("says which kind of event it is, on the wire", () => { + const hub = createChannelEventHub(); + const received: string[] = []; + hub.register("user-1", (payload) => received.push(payload)); + + hub.deliver(event()); + hub.deliver(notification()); + + expect(received.map((payload) => JSON.parse(payload).type)).toEqual([ + "channel.activity", + "notification", + ]); + }); + + test("a notification carries the Bot a click has to land on", () => { + const hub = createChannelEventHub(); + const received: string[] = []; + hub.register("user-1", (payload) => received.push(payload)); + + hub.deliver(notification()); + + const delivered = JSON.parse(received[0] as string) as NotificationEvent; + // Without this the interruption costs the attention and then makes the person go looking, which + // is worse than not having interrupted them. + expect(delivered.notification.botId).toBe("risk-analyst"); + expect(delivered.notification.detail).toBe( + "This page is asking for a code sent to your phone.", + ); + }); }); const databaseUrl = @@ -131,7 +198,7 @@ afterAll(async () => { * trip a second instance would take: a write announces, and a listener that shares nothing with the * writer but the database hears it. */ -describe("channel activity delivery", () => { +describe("live event delivery", () => { test("announces a recorded message to a listener on its own connection", async () => { const id = `${testPrefix}-user-${randomUUID()}`; await database.insert(users).values({ @@ -160,7 +227,7 @@ describe("channel activity delivery", () => { resolve(); }); }); - const listener = await startChannelActivityListener(databaseUrl, hub); + const listener = await startLiveEventListener(databaseUrl, hub); try { await store.recordActivity(owner, channel.id, { @@ -180,10 +247,55 @@ describe("channel activity delivery", () => { expect(delivered).toHaveLength(1); expect(delivered[0]).toMatchObject({ + type: "channel.activity", channelId: channel.id, lastMessage: "Categorized three expenses.", lastMessageAgentId: profile.id, - memberIds: [owner.id], + recipientIds: [owner.id], + }); + }); + + /** + * The same round trip for a notification, which is the one that had to work across processes to be + * worth building: the person is connected to whichever instance answered their upgrade, and the + * handover that raises the notification is served by whichever instance answered that request. + * Nothing arranges for those to be the same one. + */ + test("announces a notification to a listener on its own connection", async () => { + const id = `${testPrefix}-user-${randomUUID()}`; + await database.insert(users).values({ + id, + email: `${id}@example.test`, + name: "Notification Test User", }); + createdUserIds.push(id); + + const hub = createChannelEventHub(); + const delivered: NotificationEvent[] = []; + const arrived = new Promise((resolve) => { + hub.register(id, (payload) => { + delivered.push(JSON.parse(payload)); + resolve(); + }); + }); + const listener = await startLiveEventListener(databaseUrl, hub); + + try { + await announceNotification( + database, + notification({ recipientIds: [id] }), + ); + await Promise.race([ + arrived, + new Promise((_, reject) => + setTimeout(() => reject(new Error("no event within 5s")), 5000), + ), + ]); + } finally { + await listener.stop(); + } + + expect(delivered).toHaveLength(1); + expect(delivered[0]?.notification.botId).toBe("risk-analyst"); }); }); diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index eff68c0..a00147d 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -7,6 +7,7 @@ import { } from "../src/computer/gateway"; import type { ActionPolicy } from "../src/computer/policy"; import type { SnapshotResult } from "../src/computer/schema"; +import type { BlockedBot } from "../src/notifications"; /** * What the gateway must guarantee, tested as properties rather than as call sequences. @@ -397,3 +398,78 @@ describe("the computer gateway", () => { expect(rows[0]?.payload.element).toBe("not in the current snapshot"); }); }); + +/** A computer that can be asked for a handover, which the acting fake above has no need to be. */ +function fakeHandoverClient() { + const state = { holder: "bot" as const, since: "", requested: true }; + const client = { + requestControl: async () => state, + requestSecret: async () => state, + forBot: () => client, + } as unknown as ComputerClient; + return client; +} + +function handoverGateway() { + const { store, rows } = fakeAudit(); + const raised: BlockedBot[] = []; + const gateway = createComputerGateway({ + client: fakeHandoverClient(), + auditStore: store, + policy: () => PERMISSIVE, + notify: (blocked) => void raised.push(blocked), + }); + return { gateway, rows, raised }; +} + +/** + * A Bot that has stopped and is waiting is the one thing here worth interrupting somebody for, and + * until it is raised nothing outside that Bot's own screen knows it happened. + */ +describe("a Bot blocked on a person", () => { + test("raises a notification where it writes the audit row for the handover", async () => { + const { gateway, rows, raised } = handoverGateway(); + + await gateway.requestHelp("default", "bot-1", ACTOR, "Sign me in, please."); + + expect(rows.map((row) => row.eventType)).toEqual([ + "computer.help_requested", + ]); + expect(raised).toEqual([ + { + kind: "help_requested", + botId: "bot-1", + userId: ACTOR.id, + detail: "Sign me in, please.", + }, + ]); + }); + + test("names the value a secret request is for, and never a ref or a value", async () => { + const { gateway, raised } = handoverGateway(); + + await gateway.requestSecret("default", "bot-1", ACTOR, { + label: "the code sent to your phone", + ref: "e12", + snapshotId: 7, + }); + + // The label is the part that tells somebody whether to get up. A ref means nothing away from the + // screen, and the value is on a path this one is not on. + expect(raised[0]?.detail).toBe("the code sent to your phone"); + }); + + test("hands over even when nothing is listening for notifications", async () => { + const { store, rows } = fakeAudit(); + const gateway = createComputerGateway({ + client: fakeHandoverClient(), + auditStore: store, + policy: () => PERMISSIVE, + }); + + // A deployment with no notification wiring is a deployment that tells nobody, not one where a + // blocked Bot cannot be rescued. + await gateway.requestHelp("default", "bot-1", ACTOR, "Sign me in, please."); + expect(rows).toHaveLength(1); + }); +}); diff --git a/server/tests/notifications.test.ts b/server/tests/notifications.test.ts new file mode 100644 index 0000000..9215cf4 --- /dev/null +++ b/server/tests/notifications.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { + isWorthInterrupting, + NOTIFICATION_DETAIL_LIMIT, + notificationFor, + notificationKinds, + summarize, +} from "../src/notifications"; + +/** + * What the rule must guarantee, and none of it is visible from a green typecheck. + * + * The four that matter: + * - a Bot blocked on a person earns an interruption + * - a person who has silenced a Bot is not interrupted by it, whatever it is doing + * - what the model said is flattened to one line before it is put in front of anybody + * - the frame carries the Bot, so a click on it lands somewhere useful + */ + +const BLOCKED = { + kind: "help_requested", + botId: "risk-analyst", + userId: "user-1", + detail: "This page is asking for a code sent to your phone.", +} as const; + +const HEARD = { muted: false }; +const SILENCED = { muted: true }; + +describe("what is worth interrupting somebody for", () => { + test("a Bot waiting on a person is", () => { + expect(isWorthInterrupting("help_requested")).toBe(true); + expect(isWorthInterrupting("secret_requested")).toBe(true); + }); + + test("every kind in the vocabulary has an answer", () => { + expect(notificationKinds.length).toBeGreaterThan(0); + for (const kind of notificationKinds) { + expect(typeof isWorthInterrupting(kind)).toBe("boolean"); + } + }); + + test("a Bot this person has silenced is not, whatever it is asking for", () => { + expect(notificationFor(BLOCKED, SILENCED)).toBeNull(); + expect( + notificationFor({ ...BLOCKED, kind: "secret_requested" }, SILENCED), + ).toBeNull(); + }); + + test("the preference is read here rather than trusted to a surface downstream", () => { + // One place decides, so anything that renders whatever it is handed is correct by construction. + // A rule that returned a notification marked "do not show this" would put the decision in every + // surface instead, and the first one to forget it would page somebody who had opted out. + expect(notificationFor(BLOCKED, HEARD)).not.toBeNull(); + expect(notificationFor(BLOCKED, SILENCED)).toBeNull(); + }); +}); + +describe("the frame a notification carries", () => { + test("names the Bot, so a click can be routed to the one that is waiting", () => { + const raised = notificationFor(BLOCKED, HEARD); + + expect(raised?.botId).toBe("risk-analyst"); + expect(raised?.kind).toBe("help_requested"); + }); + + test("says what happened in the product's words and what the Bot said in its own", () => { + const raised = notificationFor(BLOCKED, HEARD); + + // The headline is fixed text, so a model that has just failed to log in is not the author of the + // sentence describing its own failure. + expect(raised?.headline).toBe("needs you at the keyboard"); + expect(raised?.detail).toBe( + "This page is asking for a code sent to your phone.", + ); + }); + + test("is stamped with the moment it was raised, and is individually identifiable", () => { + const at = new Date("2026-08-15T10:00:00.000Z"); + const first = notificationFor(BLOCKED, HEARD, at); + const second = notificationFor(BLOCKED, HEARD, at); + + expect(first?.at).toBe("2026-08-15T10:00:00.000Z"); + // Two raisings of the same thing are two notifications. A surface keys a toast on the id, and + // sharing one would silently swallow the second time a Bot asked for help. + expect(first?.id).not.toBe(second?.id); + }); +}); + +describe("summarize", () => { + test("flattens a model's paragraphs into one line", () => { + expect(summarize("The login page\nwants a code.\n\nPlease help.")).toBe( + "The login page wants a code. Please help.", + ); + }); + + test("unwraps a fenced block rather than dropping what is inside it", () => { + // A Bot that puts the whole of what it needs inside backticks would otherwise produce an empty + // notification, which is the one outcome worse than an untidy one. + expect(summarize("Run this:\n```bash\nnpm login\n```\nthen tell me.")).toBe( + "Run this: npm login then tell me.", + ); + expect(summarize("```\nsign in here\n```")).toBe("sign in here"); + }); + + test("removes control characters a page could have put in front of the model", () => { + expect(summarize("Sign in now\u001b[31m.")).toBe("Sign in now [31m."); + }); + + test("clips with an ellipsis at the limit, and leaves shorter text alone", () => { + const long = "a".repeat(NOTIFICATION_DETAIL_LIMIT + 40); + const clipped = summarize(long); + + expect(Array.from(clipped)).toHaveLength(NOTIFICATION_DETAIL_LIMIT); + expect(clipped.endsWith("…")).toBe(true); + + const exact = "b".repeat(NOTIFICATION_DETAIL_LIMIT); + expect(summarize(exact)).toBe(exact); + }); + + test("counts what it clips in code points, not UTF-16 units", () => { + // Sliced by string index, a clip lands between the halves of a surrogate pair and leaves a + // replacement character in front of somebody. + const clipped = summarize("🙂".repeat(20), 10); + + expect(Array.from(clipped)).toHaveLength(10); + expect(clipped.includes("�")).toBe(false); + }); + + test("survives a Bot that said nothing useful", () => { + expect(summarize(" \n\n ")).toBe(""); + expect(summarize("```")).toBe(""); + }); +}); diff --git a/server/tests/schema.test.ts b/server/tests/schema.test.ts index b95cd00..7b067c2 100644 --- a/server/tests/schema.test.ts +++ b/server/tests/schema.test.ts @@ -193,6 +193,16 @@ describe("OpenBot database schema", () => { hasDefault: false, primary: false, }, + // Nullable and without a default, like `hidden_at` beside it. A person who has never said + // anything about a Bot has no row at all, so the absence of one has to be the answer to every + // question this table can be asked. + { + name: "notifications_muted_at", + sqlType: "timestamp with time zone", + notNull: false, + hasDefault: false, + primary: false, + }, ]); expect( From e419e0d23bc2fe6cddf29476191eb2022117ba54 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 19 Aug 2026 13:50:30 -0700 Subject: [PATCH 2/5] Show the person the Bot that is waiting for them Three surfaces, in order of how much they are allowed to be missed. A toast in the corner catches an eye that is elsewhere and goes away on its own. A marker on the Bot's row in the sidebar is the trace the toast leaves, and it stays until somebody opens the Bot, because a toast nobody read has not answered anything. The operating system's own notification is behind a switch, for the case where OpenBot is not the window at all. Clicking the toast goes to the Bot with its screen open, because the prompt that unblocks it lives in that pane; sending somebody to the transcript would be the right conversation and the wrong half of it. The marker is in localStorage rather than in React state, so refreshing a tab does not answer a Bot's question. It costs being per browser, which is honest: the socket does not replay either, so a person signed in elsewhere sees only what arrives while they are there. What has already been announced is seeded from what was outstanding at mount, or every reload would greet somebody with a toast, and a desktop notification, for every Bot that ever went unanswered. The desktop switch is the only thing in the product that asks the browser for permission, and it asks only when somebody turns it on. A prompt on load is usually answered with Block, and Block is permanent enough that the feature is then gone for good. It stores its answer in this browser, not on the account, because the permission is granted per browser and a server-side yes this browser has never granted would be a promise the product cannot keep. Silencing one Bot sits on that Bot's profile next to Hide, which is the same kind of decision, and the copy says what it does not do: the Bot still asks, its screen still shows the prompt, and the trail still records the handover. --- app/src/components/agents/agent-profile.tsx | 37 +++- .../components/app-sidebar/app-sidebar.tsx | 14 +- app/src/components/app-sidebar/channel.tsx | 17 ++ .../notifications/waiting-toasts.tsx | 204 ++++++++++++++++++ .../settings/desktop-notifications.tsx | 110 ++++++++++ app/src/lib/agents/mutations.ts | 21 ++ app/src/lib/agents/queries.ts | 7 + app/src/lib/channels/use-channel-events.ts | 46 +++- app/src/lib/notifications/desktop.ts | 91 ++++++++ app/src/lib/notifications/waiting.ts | 170 +++++++++++++++ app/src/routes/_authed/_app.tsx | 7 + .../_authed/_app/channel/$channelId.tsx | 17 ++ app/src/routes/_authed/settings/index.tsx | 14 ++ app/tests/notifications.test.ts | 111 ++++++++++ 14 files changed, 860 insertions(+), 6 deletions(-) create mode 100644 app/src/components/notifications/waiting-toasts.tsx create mode 100644 app/src/components/settings/desktop-notifications.tsx create mode 100644 app/src/lib/notifications/desktop.ts create mode 100644 app/src/lib/notifications/waiting.ts create mode 100644 app/tests/notifications.test.ts diff --git a/app/src/components/agents/agent-profile.tsx b/app/src/components/agents/agent-profile.tsx index e6fcfe1..e4197d3 100644 --- a/app/src/components/agents/agent-profile.tsx +++ b/app/src/components/agents/agent-profile.tsx @@ -11,6 +11,7 @@ import { deleteAgentMutationOptions, duplicateAgentMutationOptions, setAgentHiddenMutationOptions, + setAgentNotificationsMutedMutationOptions, updateAgentMutationOptions, } from "@/lib/agents/mutations"; import { agentQueryOptions } from "@/lib/agents/queries"; @@ -68,6 +69,9 @@ export function AgentProfile({ agentId }: { agentId: string }) { duplicateAgentMutationOptions(queryClient), ); const setHidden = useMutation(setAgentHiddenMutationOptions(queryClient)); + const setNotificationsMuted = useMutation( + setAgentNotificationsMutedMutationOptions(queryClient), + ); const deleteAgent = useMutation(deleteAgentMutationOptions(queryClient)); if (agent.isPending) { @@ -83,7 +87,10 @@ export function AgentProfile({ agentId }: { agentId: string }) { const profile = agent.data; const actionError = - duplicateAgent.error ?? setHidden.error ?? deleteAgent.error; + duplicateAgent.error ?? + setHidden.error ?? + setNotificationsMuted.error ?? + deleteAgent.error; return (
@@ -203,6 +210,34 @@ export function AgentProfile({ agentId }: { agentId: string }) {

) : null} + {/* + * Beside Hide, because it is the same kind of thing: an opinion you hold about this Bot + * that changes nothing for anybody else and nothing about what the Bot may do. + */} + + + {profile.notificationsMuted ? ( +

+ You will not be told when this Bot stops and waits for you. It + still asks, its screen still shows the prompt, and the audit trail + still records the handover. +

+ ) : null} + {profile.canManage ? (
diff --git a/app/src/components/notifications/waiting-toasts.tsx b/app/src/components/notifications/waiting-toasts.tsx new file mode 100644 index 0000000..9282bef --- /dev/null +++ b/app/src/components/notifications/waiting-toasts.tsx @@ -0,0 +1,204 @@ +import { IconX } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useEffect, useState } from "react"; +import { channelListQueryOptions } from "@/lib/channels/queries"; +import { + DESKTOP_NOTIFICATIONS_STORAGE_KEY, + parseDesktopNotificationsPreference, + showDesktopNotification, +} from "@/lib/notifications/desktop"; +import { + type BotNotification, + clearBotWaiting, + useWaitingBots, +} from "@/lib/notifications/waiting"; +import { Button } from "../ui/button"; + +/** + * The corner of the screen where a Bot says it has stopped and is waiting for you. + * + * Deliberately the least of the three surfaces this fact has. The audit trail records the handover, + * the Bot's own screen shows the prompt that answers it, and the sidebar keeps a marker until + * somebody opens the Bot. This is the one that catches an eye that is elsewhere, and it is allowed + * to be missed: it goes away on its own, and nothing depends on it having been read. + * + * A click goes to the Bot with its screen open, because the screen is where the prompt that unblocks + * it lives. Sending somebody to a transcript would be sending them to the right conversation and the + * wrong pane. + */ + +/** + * How long a toast stays. + * + * Long enough to notice out of the corner of an eye and act on, short enough that the corner of the + * screen does not accumulate. It can afford to be short because the sidebar marker is the thing that + * persists; nothing is lost when this goes. + */ +const VISIBLE_MS = 12_000; + +const ENTRANCE_SECONDS = 0.2; +const EASE_OUT = [0.23, 1, 0.32, 1] as const; + +export function WaitingToasts() { + const waiting = useWaitingBots(); + const navigate = useNavigate(); + const channels = useQuery(channelListQueryOptions()); + /** + * Which notifications have already been announced. + * + * Seeded from what was already outstanding when this mounted, and that seeding is the point. The + * marker store survives a reload, so a set that started empty would greet somebody with a toast, + * and a desktop notification, for every Bot that has ever gone unanswered, every time they opened + * the app. Only what arrives while this is mounted is announced. + */ + const [announced] = useState( + () => + new Set(Object.values(waiting).map((notification) => notification.id)), + ); + const [showing, setShowing] = useState([]); + + const names = new Map( + (channels.data ?? []).flatMap((channel) => + channel.agentIds.map((agentId) => [agentId, channel.name] as const), + ), + ); + /** The Bot's name where the roster knows it. An id is a poor label, and better than none. */ + const nameOf = (botId: string) => names.get(botId) ?? botId; + + // biome-ignore lint/correctness/useExhaustiveDependencies: `names` is rebuilt every render and is read, not depended on. + useEffect(() => { + const arrived = Object.values(waiting).filter( + (notification) => !announced.has(notification.id), + ); + if (arrived.length === 0) return; + for (const notification of arrived) { + announced.add(notification.id); + // The operating system's own notification, only where somebody turned it on. See desktop.ts: + // nothing here ever asks for the permission. + if (desktopNotificationsWanted()) { + showDesktopNotification(nameOf(notification.botId), notification); + } + } + setShowing((current) => [...current, ...arrived]); + + const timer = window.setTimeout(() => { + const expiring = new Set(arrived.map((notification) => notification.id)); + setShowing((current) => + current.filter((notification) => !expiring.has(notification.id)), + ); + }, VISIBLE_MS); + return () => window.clearTimeout(timer); + }, [waiting]); + + const dismiss = (id: string) => + setShowing((current) => + current.filter((notification) => notification.id !== id), + ); + + const open = async (notification: BotNotification) => { + dismiss(notification.id); + // Cleared on the way, not on arrival. Opening the Bot is the answer to the question, and waiting + // for the destination to render would leave the marker up if the navigation failed. + clearBotWaiting(notification.botId); + const channel = (channels.data ?? []).find((candidate) => + candidate.agentIds.includes(notification.botId), + ); + if (channel) { + await navigate({ + to: "/channel/$channelId", + params: { channelId: channel.id }, + search: { watch: true }, + }); + return; + } + // A Bot nobody has started a channel with still has somewhere to be opened. + await navigate({ to: "/bot", search: { agent: notification.botId } }); + }; + + return ( +
+ + {showing.map((notification) => ( + dismiss(notification.id)} + onOpen={() => void open(notification)} + /> + ))} + +
+ ); +} + +function desktopNotificationsWanted(): boolean { + try { + return parseDesktopNotificationsPreference( + window.localStorage.getItem(DESKTOP_NOTIFICATIONS_STORAGE_KEY), + ); + } catch { + return false; + } +} + +function Toast({ + botName, + notification, + onDismiss, + onOpen, +}: { + botName: string; + notification: BotNotification; + onDismiss: () => void; + onOpen: () => void; +}) { + const shouldReduceMotion = useReducedMotion(); + return ( + +
+ + {/* The whole card opens the Bot; the close button is the only other target inside it. */} + + +
+
+ ); +} diff --git a/app/src/components/settings/desktop-notifications.tsx b/app/src/components/settings/desktop-notifications.tsx new file mode 100644 index 0000000..f41411d --- /dev/null +++ b/app/src/components/settings/desktop-notifications.tsx @@ -0,0 +1,110 @@ +import { useState } from "react"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { Switch } from "@/components/ui/switch"; +import { + DESKTOP_NOTIFICATIONS_STORAGE_KEY, + type DesktopPermission, + parseDesktopNotificationsPreference, + requestDesktopNotifications, +} from "@/lib/notifications/desktop"; + +/** + * The switch that asks the browser for permission, and the only thing in the product that does. + * + * Turning it on is the whole consent. Nothing on load asks, nothing on a first notification asks, + * and a person who never opens this page is never prompted — which is the point, because a prompt + * shown before somebody knows what it is for is usually answered with Block, and Block is close + * enough to permanent that the feature is then gone. + * + * The switch refuses to sit at "on" when the browser has said no. A control that reports a state the + * product cannot deliver is worse than one that is off: somebody would rely on it and miss the Bot + * that was waiting for them. + */ +export function DesktopNotificationsSetting() { + const [enabled, setEnabled] = useState(() => storedPreference()); + const [refusal, setRefusal] = useState(null); + + const change = async (wanted: boolean) => { + if (!wanted) { + setRefusal(null); + store(false); + setEnabled(false); + return; + } + + const answer = await requestDesktopNotifications(); + if (answer !== "granted") { + setRefusal(answer); + store(false); + setEnabled(false); + return; + } + setRefusal(null); + store(true); + setEnabled(true); + }; + + return ( + + + Desktop notifications + + Tell me when one of my Bots has stopped and is waiting for me, even + when OpenBot is not the window I am looking at. + {refusal === "denied" ? ( + + This browser is blocking notifications from OpenBot. Allow them in + its site settings and turn this on again. + + ) : null} + {refusal === "unsupported" ? ( + + This browser does not support notifications. Bots that are waiting + still show in the sidebar. + + ) : null} + + + + void change(wanted)} + /> + + + ); +} + +/* + * Storage failures are swallowed on both sides. + * + * A browser with storage blocked keeps the preference for this tab and forgets it on reload, which + * is a smaller loss than a settings page that throws while somebody is reading it. + */ +function storedPreference(): boolean { + try { + return parseDesktopNotificationsPreference( + window.localStorage.getItem(DESKTOP_NOTIFICATIONS_STORAGE_KEY), + ); + } catch { + return false; + } +} + +function store(enabled: boolean) { + try { + window.localStorage.setItem( + DESKTOP_NOTIFICATIONS_STORAGE_KEY, + enabled ? "on" : "off", + ); + } catch { + // See above. + } +} diff --git a/app/src/lib/agents/mutations.ts b/app/src/lib/agents/mutations.ts index a8f8eef..ecd64ff 100644 --- a/app/src/lib/agents/mutations.ts +++ b/app/src/lib/agents/mutations.ts @@ -89,6 +89,27 @@ export function setAgentHiddenMutationOptions(queryClient: QueryClient) { }); } +/** + * Silence one Bot's notifications, or let it speak again. + * + * Two endpoints rather than one taking a boolean, matching hide and unhide: a person pressing a + * switch is asking for a state, and a toggle that arrives twice because the network was slow leaves + * them with the opposite of what they asked for. + */ +export function setAgentNotificationsMutedMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { agentId: string; muted: boolean }) => { + await agentRequest( + `/api/agents/${variables.agentId}/notifications/${variables.muted ? "mute" : "unmute"}`, + { method: "POST" }, + ); + }, + onSuccess: () => invalidateAgents(queryClient), + }); +} + export function deleteAgentMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: async (agentId: string) => { diff --git a/app/src/lib/agents/queries.ts b/app/src/lib/agents/queries.ts index 91c5224..f5384b9 100644 --- a/app/src/lib/agents/queries.ts +++ b/app/src/lib/agents/queries.ts @@ -20,6 +20,13 @@ export type AgentProfile = { /** Whether a key is set for it. Never the key itself. */ hasAuth: boolean; hidden: boolean; + /** + * Whether this person has silenced this Bot's notifications. + * + * Per person, like `hidden`. Silencing changes nothing about what the Bot does or what the audit + * trail records; it only stops this person being interrupted by it. + */ + notificationsMuted: boolean; systemOwned: boolean; canManage: boolean; /** diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts index 4d4efe7..e89a9b3 100644 --- a/app/src/lib/channels/use-channel-events.ts +++ b/app/src/lib/channels/use-channel-events.ts @@ -1,21 +1,39 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; +import { + type BotNotification, + noteBotWaiting, +} from "@/lib/notifications/waiting"; import { type ChannelSummary, channelKeys } from "./queries"; /** - * Keep the roster live. + * Keep the roster live, and hear about a Bot that has stopped and is waiting. * * The query remains the source of truth; socket events only patch its cache. Reconnects refetch the * list to recover events missed while disconnected. + * + * One socket for both, because it is one socket: the server tags what it sends and this dispatches + * on the tag. A second connection would need its own upgrade, its own reconnect and its own backoff + * to carry a payload this one is already open for. An event whose tag this build does not recognise + * is ignored rather than guessed at, so a tab left open across a deploy skips what it cannot read + * instead of corrupting its roster with it. */ type ChannelActivityEvent = { + type: "channel.activity"; channelId: string; lastMessage: string | null; lastMessageAt: string | null; lastMessageAgentId: string | null; }; +type NotificationEvent = { + type: "notification"; + notification: BotNotification; +}; + +type LiveEvent = ChannelActivityEvent | NotificationEvent; + const FIRST_RETRY_MS = 500; const MAX_RETRY_MS = 30_000; @@ -45,13 +63,26 @@ export function useChannelEvents() { }; socket.onmessage = (message) => { - let activity: ChannelActivityEvent; + let event: LiveEvent; try { - activity = JSON.parse(message.data as string); + event = JSON.parse(message.data as string); } catch { return; } + switch (event.type) { + case "notification": + noteBotWaiting(event.notification); + return; + case "channel.activity": + break; + default: + // A tag this build has never heard of, from a server that has been deployed since this + // tab was opened. Skipped, not guessed at. + return; + } + const activity = event; + queryClient.setQueryData( channelKeys.list(), (channels: ChannelSummary[] | undefined) => { @@ -70,7 +101,14 @@ export function useChannelEvents() { const previous = channels[index]; if (!previous) return channels; - const patched = { ...previous, ...activity }; + // Named fields rather than a spread of the event. The event carries a tag as well as the + // preview, and a spread would put it in the cached row for no reason. + const patched = { + ...previous, + lastMessage: activity.lastMessage, + lastMessageAt: activity.lastMessageAt, + lastMessageAgentId: activity.lastMessageAgentId, + }; const next = channels.slice(); next[index] = patched; next.sort(byRecency); diff --git a/app/src/lib/notifications/desktop.ts b/app/src/lib/notifications/desktop.ts new file mode 100644 index 0000000..85093ad --- /dev/null +++ b/app/src/lib/notifications/desktop.ts @@ -0,0 +1,91 @@ +import type { BotNotification } from "./waiting"; + +/** + * The operating system's own notification, behind an opt-in nobody is asked for. + * + * A browser permission prompt on load is the single most disliked thing a web application does, and + * it is also self-defeating: asked before they know what the product is for, most people press + * Block, and Block is permanent enough that the feature is then gone for good. So nothing here runs + * until somebody turns the switch on, and the switch is the only thing that asks. + * + * The preference is stored in this browser rather than on the person's account, because the thing it + * governs is granted per browser. A server-side "yes" that this browser has never granted would be a + * promise the product cannot keep, and somebody would trust it and miss a Bot waiting on them. On a + * new machine the switch is off and the answer is honest: this browser has not been asked yet. + */ + +export const DESKTOP_NOTIFICATIONS_STORAGE_KEY = + "openbot-desktop-notifications"; + +/** Off unless this browser was explicitly turned on. Anything else reads as off. */ +export function parseDesktopNotificationsPreference( + value: string | null, +): boolean { + return value === "on"; +} + +/** + * What happened when somebody asked for desktop notifications. + * + * `unsupported` is separate from `denied` because they need different sentences. Denied is a + * decision this person made and can revisit in their browser; unsupported is a browser that has + * nothing to revisit, and telling them to check their settings would send them looking for a control + * that is not there. + */ +export type DesktopPermission = "granted" | "denied" | "unsupported"; + +type PermissionApi = { + permission: NotificationPermission; + requestPermission: () => Promise; +}; + +function permissionApi(): PermissionApi | null { + if (typeof window === "undefined" || !("Notification" in window)) return null; + return { + permission: Notification.permission, + requestPermission: () => Notification.requestPermission(), + }; +} + +/** + * Ask for permission, once, because somebody just asked for this. + * + * Only ever called from the switch. Every other path in the product reads the answer and never asks + * for it, so there is exactly one place a prompt can come from and it is one the person opened. + */ +export async function requestDesktopNotifications( + api: PermissionApi | null = permissionApi(), +): Promise { + if (!api) return "unsupported"; + if (api.permission === "granted") return "granted"; + // A browser that has already been told no does not show the prompt again; asking anyway resolves + // immediately with the old answer, so this needs no special case beyond reporting it honestly. + const answer = await api.requestPermission(); + return answer === "granted" ? "granted" : "denied"; +} + +/** + * Show one, if this browser is allowed to and this person asked for it. + * + * Silently does nothing otherwise. The toast has already appeared and the sidebar marker is already + * set, so this is the third of three ways the same fact is being told; failing loudly about the one + * that is a nicety would be out of proportion. + */ +export function showDesktopNotification( + botName: string, + notification: BotNotification, +): void { + if (typeof window === "undefined" || !("Notification" in window)) return; + if (Notification.permission !== "granted") return; + try { + new Notification(`${botName} ${notification.headline}`, { + body: notification.detail, + // Tagged by Bot, so a Bot that asks twice replaces its own notification rather than stacking + // two of them in the corner of somebody's screen saying the same thing. + tag: `openbot-waiting-${notification.botId}`, + }); + } catch { + // Some browsers refuse construction outside a service worker even with permission granted. The + // toast is the surface that always works; this one is allowed to be absent. + } +} diff --git a/app/src/lib/notifications/waiting.ts b/app/src/lib/notifications/waiting.ts new file mode 100644 index 0000000..a1b4730 --- /dev/null +++ b/app/src/lib/notifications/waiting.ts @@ -0,0 +1,170 @@ +import { useSyncExternalStore } from "react"; + +/** + * Which Bots are waiting for this person, kept where a dismissed toast cannot take it with it. + * + * A toast is gone in a few seconds whether or not anybody read it, and the thing it was announcing + * has not gone anywhere: a Bot stopped at a login wall is still stopped. So the toast is the + * announcement and this is the record, and the sidebar marker reads from here. Somebody who was + * making coffee finds the Bot by looking, which is the whole point. + * + * Kept in `localStorage` rather than in React state so a reload does not clear it. Reloading a tab + * is not an answer to a Bot's question, and the marker disappearing when somebody refreshes would + * make the product look like it had forgotten. The cost is that this is per browser: signing in + * somewhere else shows nothing, which is honest, because the socket does not replay either. + * + * Deliberately not on the server. A row per outstanding notification would be a second answer to + * "is this Bot waiting", and the first one already exists and is authoritative: the computer's own + * control state, which the screen polls. + */ + +/** A notification as it arrives from the server. See server/src/notifications.ts. */ +export type BotNotification = { + id: string; + /** + * Left as a plain string on purpose. + * + * The server owns the vocabulary and will grow it. A browser that has not been reloaded since the + * last deploy should still show a notification of a kind it has never heard of, because the server + * has already sent the words to render; narrowing this to a union here would turn a new kind into + * a blank toast on every stale tab. + */ + kind: string; + botId: string; + headline: string; + detail: string; + at: string; +}; + +/** One outstanding notification per Bot, keyed by the Bot, which is what the marker is attached to. */ +export type WaitingBots = Record; + +export const WAITING_STORAGE_KEY = "openbot-bots-waiting"; + +const EMPTY: WaitingBots = {}; + +/** + * Read what was stored, and treat anything unreadable as nothing. + * + * A stored value that cannot be parsed is from an older shape or a hand-edited key, and throwing + * over it would take the whole sidebar down over a decoration. + */ +export function parseWaitingBots(raw: string | null): WaitingBots { + if (!raw) return EMPTY; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return EMPTY; + } + const entries = Object.entries(parsed as Record).filter( + ([, value]) => isNotification(value), + ); + return Object.fromEntries(entries) as WaitingBots; + } catch { + return EMPTY; + } +} + +function isNotification(value: unknown): value is BotNotification { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.botId === "string" && + typeof candidate.headline === "string" && + typeof candidate.detail === "string" + ); +} + +/** + * Record that a Bot is waiting. + * + * One per Bot, so a Bot that asks twice replaces its own entry rather than accumulating. The marker + * answers "is this Bot waiting for me", which has one answer however many times it has asked, and a + * count would invite somebody to clear them one at a time. + * + * Returns the same object when nothing changed, so a repeated event does not re-render the roster. + */ +export function withWaitingBot( + waiting: WaitingBots, + notification: BotNotification, +): WaitingBots { + if (waiting[notification.botId]?.id === notification.id) return waiting; + return { ...waiting, [notification.botId]: notification }; +} + +/** Forget a Bot, which is what opening it means. Returns the same object when there was nothing. */ +export function withoutWaitingBot( + waiting: WaitingBots, + botId: string, +): WaitingBots { + if (!waiting[botId]) return waiting; + const { [botId]: _cleared, ...remaining } = waiting; + return remaining; +} + +/** + * The live copy, held outside React. + * + * A module-level value rather than a context, because the two places that read it are far apart — + * the sidebar and the toasts — and the thing that writes it is a socket handler that belongs to + * neither. `useSyncExternalStore` needs a snapshot with stable identity, which is why this is + * replaced rather than mutated. + */ +let current: WaitingBots = EMPTY; +let loaded = false; +const listeners = new Set<() => void>(); + +function storage(): Storage | null { + try { + return window.localStorage; + } catch { + // Blocked storage is a browser setting, not a failure worth surfacing. The marker then lasts + // until the tab is reloaded, which is still better than nothing. + return null; + } +} + +function snapshot(): WaitingBots { + if (!loaded) { + current = parseWaitingBots(storage()?.getItem(WAITING_STORAGE_KEY) ?? null); + loaded = true; + } + return current; +} + +function commit(next: WaitingBots) { + if (next === current) return; + current = next; + loaded = true; + try { + storage()?.setItem(WAITING_STORAGE_KEY, JSON.stringify(next)); + } catch { + // A quota failure loses persistence, not the marker in front of the person right now. + } + for (const listener of listeners) listener(); +} + +/** Announce that a Bot is waiting. Called by whatever is listening to the socket. */ +export function noteBotWaiting(notification: BotNotification) { + commit(withWaitingBot(snapshot(), notification)); +} + +/** Clear a Bot's marker. Called when somebody opens it, which is the only thing that answers it. */ +export function clearBotWaiting(botId: string) { + commit(withoutWaitingBot(snapshot(), botId)); +} + +/** Which Bots are waiting, live. */ +export function useWaitingBots(): WaitingBots { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + snapshot, + // The server never renders this, and there is nothing to render before the browser has read its + // own storage. + () => EMPTY, + ); +} diff --git a/app/src/routes/_authed/_app.tsx b/app/src/routes/_authed/_app.tsx index 7b46a49..fc49610 100644 --- a/app/src/routes/_authed/_app.tsx +++ b/app/src/routes/_authed/_app.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; import { AppSidebar } from "@/components/app-sidebar/app-sidebar"; +import { WaitingToasts } from "@/components/notifications/waiting-toasts"; import { SidebarProvider } from "@/components/ui/sidebar"; export const Route = createFileRoute("/_authed/_app")({ @@ -23,6 +24,12 @@ function RouteComponent() {
+ {/* + * Outside the scrolling pane, because a Bot waiting for you is not about whichever screen you + * are on. Sits above everything in the shell and takes pointer events only on the cards + * themselves, so it never covers the thing somebody was in the middle of. + */} + ); } diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx index 5af2dfa..3036b43 100644 --- a/app/src/routes/_authed/_app/channel/$channelId.tsx +++ b/app/src/routes/_authed/_app/channel/$channelId.tsx @@ -13,6 +13,7 @@ import { DetailPanel } from "@/components/layout/detail-panel"; import { Button } from "@/components/ui/button"; import { type AgentChannel, channelQueryOptions } from "@/lib/channels/queries"; import { onComputerActivity } from "@/lib/copilot/computer-activity"; +import { clearBotWaiting, useWaitingBots } from "@/lib/notifications/waiting"; const chatSearchSchema = z.object({ settings: z.boolean().optional(), @@ -65,6 +66,22 @@ function RouteComponent() { /** Needs-you state is rendered by the screen when the screen is already open. */ const needsYou = useNeedsYou(agentId, !isWatching); + /* + * A Bot cannot be marked as unseen while it is the one on screen. + * + * Not on arrival alone, because a Bot can ask while somebody is already looking at it, and a + * marker that only the act of navigating could clear would then sit on the row they are on. On + * every change instead, which also covers arriving from the sidebar, from a bookmark, or from a + * toast; a marker only the toast could clear would stay up for anybody who did not use one. + * + * The toast still appears for the Bot on screen. The two say different things: the toast says this + * has just happened, and the marker says this is outstanding and nobody has looked yet. + */ + const waitingBots = useWaitingBots(); + useEffect(() => { + if (agentId && waitingBots[agentId]) clearBotWaiting(agentId); + }, [agentId, waitingBots]); + // Needs-you prompts auto-open the screen because the actionable prompt is rendered there. useEffect(() => { if (!needsYou) return; diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx index cf2c859..55c547b 100644 --- a/app/src/routes/_authed/settings/index.tsx +++ b/app/src/routes/_authed/settings/index.tsx @@ -4,6 +4,7 @@ import { PageSection, PageShell, } from "@/components/layout/page-shell"; +import { DesktopNotificationsSetting } from "@/components/settings/desktop-notifications"; import { useTheme } from "@/components/theme-provider"; import { Item, @@ -50,6 +51,19 @@ function RouteComponent() { + {/* + * Its own section rather than a third row under General, because unlike the theme this one is + * about this browser and not about the account, and the description has to be able to say so. + */} + + + +

+ This applies to this browser only, because the permission is granted + per browser. Silencing an individual Bot is on that Bot's profile. +

+
+
); } diff --git a/app/tests/notifications.test.ts b/app/tests/notifications.test.ts new file mode 100644 index 0000000..9120c7c --- /dev/null +++ b/app/tests/notifications.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { + parseDesktopNotificationsPreference, + requestDesktopNotifications, +} from "@/lib/notifications/desktop"; +import { + type BotNotification, + parseWaitingBots, + withoutWaitingBot, + withWaitingBot, +} from "@/lib/notifications/waiting"; + +function notification(overrides: Partial = {}) { + return { + id: "notification-1", + kind: "help_requested", + botId: "risk-analyst", + headline: "needs you at the keyboard", + detail: "This page is asking for a code sent to your phone.", + at: "2026-08-15T10:00:00.000Z", + ...overrides, + } satisfies BotNotification; +} + +describe("the record of which Bots are waiting", () => { + test("keeps one entry per Bot, so asking twice does not accumulate", () => { + const first = withWaitingBot({}, notification()); + const again = withWaitingBot( + first, + notification({ id: "notification-2", detail: "Still waiting." }), + ); + + expect(Object.keys(again)).toEqual(["risk-analyst"]); + expect(again["risk-analyst"]?.detail).toBe("Still waiting."); + }); + + test("returns the same object for an event it has already recorded", () => { + const waiting = withWaitingBot({}, notification()); + + // The roster renders from this. A repeated event that produced a new object would re-render + // every row for a marker that has not changed. + expect(withWaitingBot(waiting, notification())).toBe(waiting); + expect(withoutWaitingBot(waiting, "somebody-else")).toBe(waiting); + }); + + test("forgets a Bot that has been opened, and only that one", () => { + const waiting = withWaitingBot( + withWaitingBot({}, notification()), + notification({ id: "notification-2", botId: "expense-manager" }), + ); + + expect(Object.keys(withoutWaitingBot(waiting, "risk-analyst"))).toEqual([ + "expense-manager", + ]); + }); + + test("survives being written down and read back", () => { + const waiting = withWaitingBot({}, notification()); + + // The marker outliving a reload is the whole reason this is stored rather than held in state: + // refreshing a tab is not an answer to a Bot's question. + expect(parseWaitingBots(JSON.stringify(waiting))).toEqual(waiting); + }); + + test("treats anything it cannot read as nothing waiting", () => { + // A value from an older shape, or one somebody edited by hand. Throwing over it would take the + // sidebar down over a decoration. + expect(parseWaitingBots(null)).toEqual({}); + expect(parseWaitingBots("not json")).toEqual({}); + expect(parseWaitingBots("[]")).toEqual({}); + expect(parseWaitingBots('{"risk-analyst":{"id":"x"}}')).toEqual({}); + }); +}); + +describe("desktop notifications", () => { + test("are off unless this browser was explicitly turned on", () => { + expect(parseDesktopNotificationsPreference("on")).toBe(true); + expect(parseDesktopNotificationsPreference("off")).toBe(false); + expect(parseDesktopNotificationsPreference(null)).toBe(false); + expect(parseDesktopNotificationsPreference("true")).toBe(false); + }); + + test("do not prompt a browser that has already granted permission", async () => { + let asked = 0; + const answer = await requestDesktopNotifications({ + permission: "granted", + requestPermission: async () => { + asked += 1; + return "granted"; + }, + }); + + expect(answer).toBe("granted"); + expect(asked).toBe(0); + }); + + test("report a refusal rather than storing an opt-in the browser will not honour", async () => { + const answer = await requestDesktopNotifications({ + permission: "default", + requestPermission: async () => "denied", + }); + + expect(answer).toBe("denied"); + }); + + test("tell a browser without notifications apart from one that said no", async () => { + // Different sentences: denied is a decision somebody can revisit in their browser, unsupported + // sends them looking for a control that is not there. + expect(await requestDesktopNotifications(null)).toBe("unsupported"); + }); +}); From 7ddea9955a051058b82322770b44beb7736ccb5e Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 19 Aug 2026 14:26:57 -0700 Subject: [PATCH 3/5] Let a toast leave, and let one reach every screen Three ways the corner of the screen was not doing what it says it does. The auto-dismiss timer belonged to the list of cards rather than to a card, so it was torn down and rebuilt every time the waiting store changed at all. The common case is the worst one: a Bot on the channel somebody already has open asks for help, the marker is set and then immediately cleared by that screen, the rebuilt effect finds nothing newly arrived and schedules nothing, and the card stays in the corner until the tab is reloaded. Two Bots asking inside the same twelve seconds stranded the first the same way. Each card now counts its own time from when it appeared, which is the only clock a store somewhere else cannot stop. Stranded cards were not merely untidy: they take pointer events, so they sat over whatever was underneath them. The socket and the cards were mounted in the app shell, and Settings and Admin are siblings of that shell rather than screens inside it, while the provider that owns the Bot's request-help tool sits above all three. A Bot could be mid-run and ask for a person who was reading the preferences page, and that ask reached nothing at all: no card, no marker, no desktop notification, and no way to recover it afterwards. The page that offers the desktop switch was a page the switch could not deliver on. Both now hang off the authed boundary, which is where the tool that raises them already is, and it is still one socket for the application. Opening a channel cleared the marker for its first coworker only, while the sidebar sets one for any of them. The API creates channels with more than one, so a marker set by the second could survive being opened and had nowhere left to go. Both ends now mean the same thing by a row. The name on the card came from the channel, which is named after everybody in it, so a two-Bot channel announced both names and said one of them was waiting. It comes from the roster now. The marker store also gains a storage listener. It kept its copy in a module variable and wrote the whole map back on every change, so a second tab whose copy predated another tab clearing a marker put that marker back, and a Bot somebody had already dealt with reappeared. The module argued the cost of this store was per browser; without the listener it was per tab. What is still not fixed is stated where it is relied on rather than left to be found: nothing on the server holds an outstanding notification and nothing re-derives the marker from control state, so a notification raised while a browser is disconnected is not delivered late. The header of the socket hook and the store's own docblock now say so instead of implying a recovery that does not exist. --- .../components/app-sidebar/app-sidebar.tsx | 7 +- .../notifications/waiting-toasts.tsx | 67 ++++++++++++++----- app/src/lib/channels/use-channel-events.ts | 11 ++- app/src/lib/notifications/waiting.ts | 31 ++++++++- app/src/routes/_authed.tsx | 33 ++++++++- app/src/routes/_authed/_app.tsx | 7 -- .../_authed/_app/channel/$channelId.tsx | 12 +++- 7 files changed, 130 insertions(+), 38 deletions(-) diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index abae48b..34d780f 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -40,7 +40,6 @@ import { type ChannelSummary, channelListQueryOptions, } from "@/lib/channels/queries"; -import { useChannelEvents } from "@/lib/channels/use-channel-events"; import { appConfig } from "@/lib/generated/application-config"; import { useWaitingBots } from "@/lib/notifications/waiting"; import { Button } from "../ui/button"; @@ -157,9 +156,9 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const navigate = useNavigate(); const signOut = useMutation(signOutMutationOptions(queryClient)); const channels = useQuery(channelListQueryOptions()); - // One socket for the app, opened where the roster is kept live. It also carries the notifications - // that set the markers below. - useChannelEvents(); + // The socket that keeps this roster live, and carries the notifications behind the markers below, + // is opened on the authed boundary. A Bot does not stop needing somebody when they leave this + // shell, so it cannot be owned by the shell. const waitingBots = useWaitingBots(); const [search, setSearch] = useState(""); const searching = search.trim().length > 0; diff --git a/app/src/components/notifications/waiting-toasts.tsx b/app/src/components/notifications/waiting-toasts.tsx index 9282bef..3ef8026 100644 --- a/app/src/components/notifications/waiting-toasts.tsx +++ b/app/src/components/notifications/waiting-toasts.tsx @@ -2,7 +2,8 @@ import { IconX } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { agentListQueryOptions } from "@/lib/agents/queries"; import { channelListQueryOptions } from "@/lib/channels/queries"; import { DESKTOP_NOTIFICATIONS_STORAGE_KEY, @@ -35,6 +36,13 @@ import { Button } from "../ui/button"; * Long enough to notice out of the corner of an eye and act on, short enough that the corner of the * screen does not accumulate. It can afford to be short because the sidebar marker is the thing that * persists; nothing is lost when this goes. + * + * Counted by each card for itself rather than by the list that holds them. A timer owned by the list + * has to be torn down whenever the list is rebuilt, and the list is rebuilt every time any Bot's + * marker changes anywhere in the product: a second Bot asking, or the first one's marker being + * cleared by somebody opening it, would cancel the countdown of a card already on screen and leave + * it there for good. That is the opposite of what this surface promises, and because the cards take + * pointer events, a stranded one goes on covering the corner of somebody's work. */ const VISIBLE_MS = 12_000; @@ -45,6 +53,15 @@ export function WaitingToasts() { const waiting = useWaitingBots(); const navigate = useNavigate(); const channels = useQuery(channelListQueryOptions()); + /** + * The roster, for the name on the card. + * + * The channel list is not that. A channel is named after everybody in it, so in a channel with two + * Bots it would put both names in front of somebody and say one of them was waiting. Read from the + * roster the name belongs to instead, and fall back to the id, which is a poor label and an honest + * one. + */ + const agents = useQuery(agentListQueryOptions()); /** * Which notifications have already been announced. * @@ -59,15 +76,22 @@ export function WaitingToasts() { ); const [showing, setShowing] = useState([]); - const names = new Map( - (channels.data ?? []).flatMap((channel) => - channel.agentIds.map((agentId) => [agentId, channel.name] as const), - ), - ); + const roster = agents.data; /** The Bot's name where the roster knows it. An id is a poor label, and better than none. */ - const nameOf = (botId: string) => names.get(botId) ?? botId; + const nameOf = useCallback( + (botId: string) => + roster?.find((agent) => agent.id === botId)?.name ?? botId, + [roster], + ); - // biome-ignore lint/correctness/useExhaustiveDependencies: `names` is rebuilt every render and is read, not depended on. + /* + * Re-running when the roster arrives is free, and refusing to would not be. + * + * `announced` is what stops a notification being told twice, so an extra run finds nothing new and + * returns. Leaving the roster out of the dependencies to avoid those runs is the version that goes + * wrong: the first notification of a session lands before the roster does, and the desktop + * notification would then carry an id where a name should be. + */ useEffect(() => { const arrived = Object.values(waiting).filter( (notification) => !announced.has(notification.id), @@ -82,15 +106,7 @@ export function WaitingToasts() { } } setShowing((current) => [...current, ...arrived]); - - const timer = window.setTimeout(() => { - const expiring = new Set(arrived.map((notification) => notification.id)); - setShowing((current) => - current.filter((notification) => !expiring.has(notification.id)), - ); - }, VISIBLE_MS); - return () => window.clearTimeout(timer); - }, [waiting]); + }, [waiting, announced, nameOf]); const dismiss = (id: string) => setShowing((current) => @@ -159,6 +175,23 @@ function Toast({ onOpen: () => void; }) { const shouldReduceMotion = useReducedMotion(); + /** + * The card's own countdown, started when it appeared and cancelled only when it goes. + * + * Reached through a ref so the effect has nothing to depend on but the mount. `onDismiss` closes + * over the list's state and is a new function on every render of it, and an effect that listed it + * would restart the countdown every time any Bot's marker changed anywhere — the slower version of + * never expiring at all. + */ + const expire = useRef(onDismiss); + useEffect(() => { + expire.current = onDismiss; + }); + useEffect(() => { + const timer = window.setTimeout(() => expire.current(), VISIBLE_MS); + return () => window.clearTimeout(timer); + }, []); + return ( { retryDelay = FIRST_RETRY_MS; - // Recover events missed while the socket was disconnected. + // Recover the roster activity missed while the socket was disconnected. Notifications are not + // recovered here; see the header. void queryClient.invalidateQueries({ queryKey: channelKeys.list() }); }; diff --git a/app/src/lib/notifications/waiting.ts b/app/src/lib/notifications/waiting.ts index a1b4730..6a5144e 100644 --- a/app/src/lib/notifications/waiting.ts +++ b/app/src/lib/notifications/waiting.ts @@ -14,8 +14,12 @@ import { useSyncExternalStore } from "react"; * somewhere else shows nothing, which is honest, because the socket does not replay either. * * Deliberately not on the server. A row per outstanding notification would be a second answer to - * "is this Bot waiting", and the first one already exists and is authoritative: the computer's own - * control state, which the screen polls. + * "is this Bot waiting", and there is already an authoritative one: the computer's own control + * state. The cost of that choice is real and is not argued away here. Nothing re-derives this record + * from control state, and nothing replays what the socket missed, so a notification raised while a + * browser was disconnected — across a deploy, say — leaves no trace on any of these three surfaces. + * The Bot is still visibly waiting on its own screen and the handover is still in the audit trail, + * which is what makes that survivable rather than good. */ /** A notification as it arrives from the server. See server/src/notifications.ts. */ @@ -155,6 +159,29 @@ export function clearBotWaiting(botId: string) { commit(withoutWaitingBot(snapshot(), botId)); } +/** + * Another tab of this browser, writing the same key. + * + * Without this the two diverge and one of them wins by accident. `current` is a module value, so a + * tab whose copy predates another tab clearing a marker writes that marker back on its next commit, + * and a Bot somebody has already dealt with reappears in their sidebar. The event does not fire in + * the tab that wrote, which is what makes listening to it safe. + * + * Registered once at import rather than by a hook, because the store is read from two places that + * mount and unmount independently and this has to hold whether or not either of them is on screen. + */ +if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + // A null key is the whole of storage being cleared, which is also news. + if (event.key !== null && event.key !== WAITING_STORAGE_KEY) return; + current = parseWaitingBots( + event.newValue ?? storage()?.getItem(WAITING_STORAGE_KEY) ?? null, + ); + loaded = true; + for (const listener of listeners) listener(); + }); +} + /** Which Bots are waiting, live. */ export function useWaitingBots(): WaitingBots { return useSyncExternalStore( diff --git a/app/src/routes/_authed.tsx b/app/src/routes/_authed.tsx index f3742d5..b28f458 100644 --- a/app/src/routes/_authed.tsx +++ b/app/src/routes/_authed.tsx @@ -1,4 +1,6 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import { WaitingToasts } from "@/components/notifications/waiting-toasts"; +import { useChannelEvents } from "@/lib/channels/use-channel-events"; import { currentUserQueryOptions } from "../lib/auth/queries"; import { CopilotProvider } from "../lib/copilot/provider"; @@ -13,9 +15,34 @@ export const Route = createFileRoute("/_authed")({ }, // Mounted INSIDE the authed boundary, not at the root: the runtime endpoint requires a session, so // a provider above the sign-in gate would open a run for a visitor who has not signed in yet. - component: () => ( + component: RouteComponent, +}); + +function RouteComponent() { + /* + * The socket, and the corner it announces into, belong to the whole signed-in application. + * + * Held here rather than in the app shell because a Bot does not stop needing somebody when they + * walk into Settings. `CopilotProvider` is on this boundary too, so a Bot can be mid-run and ask + * for help while its person is reading the preferences page, and a socket that only existed + * alongside the channel rail would mean that ask reached nobody at all: no toast, no marker, no + * desktop notification, and nothing to recover it afterwards. Settings is also where the desktop + * notification switch lives, so the page that offers the feature was the page it could not reach. + * + * One socket for the application, still. Every screen under here shares this one, and it is the + * roster query's live patch as well as the notification carrier. + */ + useChannelEvents(); + + return ( + {/* + * Fixed to the viewport and outside every screen's scroller, because a Bot waiting for you is + * not about whichever screen you are on. It takes pointer events only on the cards themselves, + * so it never covers the thing somebody was in the middle of. + */} + - ), -}); + ); +} diff --git a/app/src/routes/_authed/_app.tsx b/app/src/routes/_authed/_app.tsx index fc49610..7b46a49 100644 --- a/app/src/routes/_authed/_app.tsx +++ b/app/src/routes/_authed/_app.tsx @@ -1,6 +1,5 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; import { AppSidebar } from "@/components/app-sidebar/app-sidebar"; -import { WaitingToasts } from "@/components/notifications/waiting-toasts"; import { SidebarProvider } from "@/components/ui/sidebar"; export const Route = createFileRoute("/_authed/_app")({ @@ -24,12 +23,6 @@ function RouteComponent() {
- {/* - * Outside the scrolling pane, because a Bot waiting for you is not about whichever screen you - * are on. Sits above everything in the shell and takes pointer events only on the cards - * themselves, so it never covers the thing somebody was in the middle of. - */} - ); } diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx index 3036b43..440818b 100644 --- a/app/src/routes/_authed/_app/channel/$channelId.tsx +++ b/app/src/routes/_authed/_app/channel/$channelId.tsx @@ -74,13 +74,21 @@ function RouteComponent() { * every change instead, which also covers arriving from the sidebar, from a bookmark, or from a * toast; a marker only the toast could clear would stay up for anybody who did not use one. * + * Every coworker in the channel, not the one the transcript is routed to. The sidebar marks a row + * when any Bot behind it is waiting, and the API will create a channel with more than one, so + * clearing only the first leaves a marker on a row that has already been opened with no way left + * to answer it. The two ends of the same marker have to agree about what a row means. + * * The toast still appears for the Bot on screen. The two say different things: the toast says this * has just happened, and the marker says this is outstanding and nobody has looked yet. */ + const memberIds = channel.data?.agentIds; const waitingBots = useWaitingBots(); useEffect(() => { - if (agentId && waitingBots[agentId]) clearBotWaiting(agentId); - }, [agentId, waitingBots]); + for (const memberId of memberIds ?? []) { + if (waitingBots[memberId]) clearBotWaiting(memberId); + } + }, [memberIds, waitingBots]); // Needs-you prompts auto-open the screen because the actionable prompt is rendered there. useEffect(() => { From 3267ef12ebc878a0ffb54226afa22668832d3e90 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 19 Aug 2026 14:27:15 -0700 Subject: [PATCH 4/5] Stop the desktop switch reporting a grant the browser took back The switch read its position from this browser's stored preference alone and consulted the permission only while somebody was pressing it. A browser grant can be withdrawn long after it was given, in site settings or by a profile clear, and none of that comes back through the tab that asked for it. So a person who turned the switch on and later revoked notifications for OpenBot reopened Settings to a switch sitting at On, with nothing said, while every notification was being dropped at the permission check. That is the exact failure the component's own comment claims it prevents, and it is worse than the switch being off, because somebody trusts it and misses the Bot. The rule now lives in a function that takes the stored preference and the permission API and returns where the switch may honestly sit, so it can be argued with in a test rather than only in a browser. The page settles the two when it opens and again whenever the tab is focused, because revoking a grant is done in the browser's own settings and the way back from there is to this tab. A withdrawal is written back to storage rather than only displayed, so nothing downstream goes on reading a yes this browser will not honour, and the sentence about a blocked browser is shown only to somebody who had asked to be notified. --- .../settings/desktop-notifications.tsx | 44 ++++++++++------ app/src/lib/notifications/desktop.ts | 39 +++++++++++++++ app/tests/notifications.test.ts | 50 +++++++++++++++++++ 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/app/src/components/settings/desktop-notifications.tsx b/app/src/components/settings/desktop-notifications.tsx index f41411d..cced62e 100644 --- a/app/src/components/settings/desktop-notifications.tsx +++ b/app/src/components/settings/desktop-notifications.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Item, ItemActions, @@ -9,8 +9,9 @@ import { import { Switch } from "@/components/ui/switch"; import { DESKTOP_NOTIFICATIONS_STORAGE_KEY, - type DesktopPermission, + type DesktopNotificationsState, parseDesktopNotificationsPreference, + reconcileDesktopNotifications, requestDesktopNotifications, } from "@/lib/notifications/desktop"; @@ -24,30 +25,45 @@ import { * * The switch refuses to sit at "on" when the browser has said no. A control that reports a state the * product cannot deliver is worse than one that is off: somebody would rely on it and miss the Bot - * that was waiting for them. + * that was waiting for them. So what was stored is settled against the live permission every time + * this is shown, and again whenever the tab is looked at, because revoking a grant is done in the + * browser's own settings and the way back from there is to this tab. */ export function DesktopNotificationsSetting() { - const [enabled, setEnabled] = useState(() => storedPreference()); - const [refusal, setRefusal] = useState(null); + const [state, setState] = useState(() => + reconcileDesktopNotifications(storedPreference()), + ); + + useEffect(() => { + const settle = () => { + const stored = storedPreference(); + const next = reconcileDesktopNotifications(stored); + // Written back, not merely displayed. Everything else in the product reads the stored value + // and would go on reading a yes this browser has withdrawn. + if (stored && !next.enabled) store(false); + setState(next); + }; + + settle(); + window.addEventListener("focus", settle); + return () => window.removeEventListener("focus", settle); + }, []); const change = async (wanted: boolean) => { if (!wanted) { - setRefusal(null); store(false); - setEnabled(false); + setState({ enabled: false, withdrawn: null }); return; } const answer = await requestDesktopNotifications(); if (answer !== "granted") { - setRefusal(answer); store(false); - setEnabled(false); + setState({ enabled: false, withdrawn: answer }); return; } - setRefusal(null); store(true); - setEnabled(true); + setState({ enabled: true, withdrawn: null }); }; return ( @@ -57,13 +73,13 @@ export function DesktopNotificationsSetting() { Tell me when one of my Bots has stopped and is waiting for me, even when OpenBot is not the window I am looking at. - {refusal === "denied" ? ( + {state.withdrawn === "denied" ? ( This browser is blocking notifications from OpenBot. Allow them in its site settings and turn this on again. ) : null} - {refusal === "unsupported" ? ( + {state.withdrawn === "unsupported" ? ( This browser does not support notifications. Bots that are waiting still show in the sidebar. @@ -74,7 +90,7 @@ export function DesktopNotificationsSetting() { void change(wanted)} /> diff --git a/app/src/lib/notifications/desktop.ts b/app/src/lib/notifications/desktop.ts index 85093ad..dc82b45 100644 --- a/app/src/lib/notifications/desktop.ts +++ b/app/src/lib/notifications/desktop.ts @@ -34,6 +34,9 @@ export function parseDesktopNotificationsPreference( */ export type DesktopPermission = "granted" | "denied" | "unsupported"; +/** The answers that are not "granted", which are the ones a person has to be told about. */ +export type DesktopRefusal = Exclude; + type PermissionApi = { permission: NotificationPermission; requestPermission: () => Promise; @@ -64,6 +67,42 @@ export async function requestDesktopNotifications( return answer === "granted" ? "granted" : "denied"; } +/** Where the switch may honestly sit. */ +export type DesktopNotificationsState = { + enabled: boolean; + /** + * Why it is off, when somebody had asked for it to be on. + * + * Null when nothing was withdrawn, which covers both "off because nobody turned it on" and "on and + * working". A sentence about a blocked browser in front of somebody who never asked for + * notifications would be an answer to a question they did not put. + */ + withdrawn: DesktopRefusal | null; +}; + +/** + * Settle what was asked for against what the browser will still do. + * + * The stored preference is not the answer on its own. A grant can be taken away long after it was + * given — somebody revokes it in site settings, or clears the profile, or the browser expires it — + * and none of that comes back through the tab that asked for it. Reading only what was stored leaves + * a switch sitting at "on" while `showDesktopNotification` quietly returns at its permission check, + * which is the one failure this control must not have: somebody trusts it, is not told, and misses + * the Bot that was waiting for them. + * + * Takes the stored preference rather than reading it, and takes the permission API rather than + * reaching for it, so the rule can be argued with in a test rather than only in a browser. + */ +export function reconcileDesktopNotifications( + stored: boolean, + api: PermissionApi | null = permissionApi(), +): DesktopNotificationsState { + if (!stored) return { enabled: false, withdrawn: null }; + if (!api) return { enabled: false, withdrawn: "unsupported" }; + if (api.permission === "granted") return { enabled: true, withdrawn: null }; + return { enabled: false, withdrawn: "denied" }; +} + /** * Show one, if this browser is allowed to and this person asked for it. * diff --git a/app/tests/notifications.test.ts b/app/tests/notifications.test.ts index 9120c7c..603f948 100644 --- a/app/tests/notifications.test.ts +++ b/app/tests/notifications.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { parseDesktopNotificationsPreference, + reconcileDesktopNotifications, requestDesktopNotifications, } from "@/lib/notifications/desktop"; import { @@ -109,3 +110,52 @@ describe("desktop notifications", () => { expect(await requestDesktopNotifications(null)).toBe("unsupported"); }); }); + +describe("the switch settled against what the browser will still do", () => { + const granted = { + permission: "granted", + requestPermission: async () => "granted", + } as const; + const denied = { + permission: "denied", + requestPermission: async () => "denied", + } as const; + + test("sits at on only while the grant it was given still stands", () => { + expect(reconcileDesktopNotifications(true, granted)).toEqual({ + enabled: true, + withdrawn: null, + }); + expect(reconcileDesktopNotifications(false, granted)).toEqual({ + enabled: false, + withdrawn: null, + }); + }); + + test("goes off when the browser has taken the grant back, and says which way", () => { + // The failure this exists for: somebody revokes notifications in site settings long after + // turning the switch on, the stored preference still says "on", and every notification is then + // dropped at the permission check with the switch reporting that they are being told. + expect(reconcileDesktopNotifications(true, denied)).toEqual({ + enabled: false, + withdrawn: "denied", + }); + expect(reconcileDesktopNotifications(true, null)).toEqual({ + enabled: false, + withdrawn: "unsupported", + }); + }); + + test("says nothing about a browser somebody never asked to be notified by", () => { + // A sentence about a blocked browser in front of somebody who has the switch off would be an + // answer to a question they did not put. + expect(reconcileDesktopNotifications(false, denied)).toEqual({ + enabled: false, + withdrawn: null, + }); + expect(reconcileDesktopNotifications(false, null)).toEqual({ + enabled: false, + withdrawn: null, + }); + }); +}); From 8e098f3708367ad6de73a2ca0118162a80750c0b Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 19 Aug 2026 14:27:39 -0700 Subject: [PATCH 5/5] Strip what is invisible too, and cover the seam behind the switch The control-character strip in summarize was added because the only source of that text is model output shaped by whatever page the Bot is on. The same threat walks straight past it as characters that are invisible rather than unprintable: a right-to-left override survives both the strip and the whitespace collapse, so a page could hand the model a reason that renders in the toast and in the operating system's notification reading backwards from the audit row describing the same handover. Zero-width joiners and spaces and the byte order mark go for the same reason. Removed rather than replaced with a space, because a character of no width is not a word boundary. Silencing a Bot had no test anywhere. The two routes, the store's write and the read the notification path makes were each only exercised by hand, and the profile field was projected into the DTO alongside rows that were all false, so a field that was never read would have passed. The integration test now walks the round trip the feature actually depends on: the switch writes, the thing that decides whether to interrupt somebody reads, it holds for one person and not another, and hiding the same Bot afterwards does not undo it, which is the failure the shared row invites. One assertion in the notification tests could not fail. Asserting that isWorthInterrupting returns a boolean for a key that exists by construction reads as coverage of "every kind states its answer out loud" and is none. It now checks the thing that can actually go wrong: that the rule and the frame, two functions reading one table, cannot disagree about a kind, and that a kind which earns an interruption has words to show for it. --- server/src/notifications.ts | 14 ++++- .../agent-profile-store.integration.test.ts | 61 +++++++++++++++++++ server/tests/agent-routes.test.ts | 35 ++++++++++- server/tests/notifications.test.ts | 25 +++++++- 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/server/src/notifications.ts b/server/src/notifications.ts index 2cefaa5..1edf1ef 100644 --- a/server/src/notifications.ts +++ b/server/src/notifications.ts @@ -155,6 +155,14 @@ export function notificationFor( * Control characters go too. This text is rendered in a toast and handed to the operating system's * own notification, and a terminal escape somebody's page put in front of the model has no business * following it into either. + * + * So do the characters that are invisible rather than unprintable: zero-width joiners and spaces, + * the byte order mark, and the bidirectional overrides and isolates. They survive a control-character + * strip and whitespace collapsing untouched, and they are the same threat by another route — a page + * can put a right-to-left override in front of the model, the model repeats it in its reason, and the + * notification then renders a sentence that reads backwards from the one the audit trail recorded. + * Removed rather than replaced with a space, because a character of no width is not a word boundary + * and substituting one would split words that were never apart. */ export function summarize( text: string, @@ -163,7 +171,11 @@ export function summarize( const unfenced = text.replaceAll(/```[^\n`]*/g, " ").replaceAll("`", ""); // biome-ignore lint/suspicious/noControlCharactersInRegex: removing them is the point. const printable = unfenced.replaceAll(/[\u0000-\u001f\u007f-\u009f]+/g, " "); - const collapsed = printable.replaceAll(/\s+/g, " ").trim(); + const visible = printable.replaceAll( + /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g, + "", + ); + const collapsed = visible.replaceAll(/\s+/g, " ").trim(); // Counted in code points rather than UTF-16 units, so a clip never lands between the halves of a // surrogate pair and leaves a replacement character in front of somebody. diff --git a/server/tests/agent-profile-store.integration.test.ts b/server/tests/agent-profile-store.integration.test.ts index a3d0691..ddefabe 100644 --- a/server/tests/agent-profile-store.integration.test.ts +++ b/server/tests/agent-profile-store.integration.test.ts @@ -264,6 +264,67 @@ describe("agent profile store integration", () => { expect(preference?.hiddenAt).toBeNull(); }); + test("stores silencing per user, and the notification path reads back what the switch wrote", async () => { + const owner = await createUser(); + const other = await createUser(); + const source = await createProfileFixture({ owner, visibility: "public" }); + + expect(await store.notificationsMuted(owner.id, source.agentId)).toBe( + false, + ); + expect((await profileById(owner, source.agentId)).notificationsMuted).toBe( + false, + ); + + await store.setNotificationsMuted(owner, source.agentId, true); + + // The seam this covers is the whole feature for a person who has silenced a Bot: the switch + // writes a row, and the thing that decides whether to interrupt them reads it. Nothing else in + // the suite joins those two ends, so a rename on either side would go unnoticed until somebody + // was paged by a Bot they had turned off. + expect(await store.notificationsMuted(owner.id, source.agentId)).toBe(true); + expect((await profileById(owner, source.agentId)).notificationsMuted).toBe( + true, + ); + // An opinion one person holds, like hiding. Everybody else still hears from this Bot. + expect(await store.notificationsMuted(other.id, source.agentId)).toBe( + false, + ); + + await store.setNotificationsMuted(owner, source.agentId, false); + expect(await store.notificationsMuted(owner.id, source.agentId)).toBe( + false, + ); + }); + + test("silencing and hiding share a row without overwriting each other", async () => { + const owner = await createUser(); + const source = await createProfileFixture({ owner, visibility: "public" }); + + await store.setNotificationsMuted(owner, source.agentId, true); + // Two preferences in one row, so an upsert that wrote the whole row from either control would + // silently undo the other. A person who hides a Bot has said nothing about being interrupted by + // it, and the reverse. + await store.setHidden(owner, source.agentId, true); + + expect(await store.notificationsMuted(owner.id, source.agentId)).toBe(true); + expectListed(await store.list(owner), source.agentId, false); + + await store.setNotificationsMuted(owner, source.agentId, false); + expectListed(await store.list(owner, true), source.agentId, true); + }); + + test("answers for a Bot nobody has an opinion about, and for one that does not exist", async () => { + const owner = await createUser(); + + // The notification path calls this with whatever the gateway handed it, before anything has + // checked that the pair means something. Not muted is the answer that costs somebody an + // unwanted notification rather than a Bot that waits all afternoon. + expect(await store.notificationsMuted(owner.id, id("absent-agent"))).toBe( + false, + ); + }); + test("takes the endpoint and ignores every field a caller must not set", async () => { const owner = await createUser(); const deploymentPackage = await createPackage(); diff --git a/server/tests/agent-routes.test.ts b/server/tests/agent-routes.test.ts index b05774e..18a3810 100644 --- a/server/tests/agent-routes.test.ts +++ b/server/tests/agent-routes.test.ts @@ -42,6 +42,7 @@ function profile(overrides: Partial = {}): AgentProfile { ownerUserId: actor.id, systemOwned: false, hidden: false, + notificationsMuted: false, deletedAt: null, ...overrides, }; @@ -81,6 +82,13 @@ function fakeStore( async setHidden(receivedActor, id, hidden) { calls.push(["setHidden", receivedActor, id, hidden]); }, + async setNotificationsMuted(receivedActor, id, muted) { + calls.push(["setNotificationsMuted", receivedActor, id, muted]); + }, + async notificationsMuted(userId, id) { + calls.push(["notificationsMuted", userId, id]); + return false; + }, async softDelete(receivedActor, id) { calls.push(["softDelete", receivedActor, id]); }, @@ -226,6 +234,8 @@ describe("agent lifecycle routes", () => { ["/agent-1/duplicate", { method: "POST" }], ["/agent-1/hide", { method: "POST" }], ["/agent-1/unhide", { method: "POST" }], + ["/agent-1/notifications/mute", { method: "POST" }], + ["/agent-1/notifications/unmute", { method: "POST" }], ["/agent-1", { method: "DELETE" }], ]; @@ -289,6 +299,14 @@ describe("agent lifecycle routes", () => { const unhidden = await app.request("http://openbot.test/agent-1/unhide", { method: "POST", }); + const muted = await app.request( + "http://openbot.test/agent-1/notifications/mute", + { method: "POST" }, + ); + const unmuted = await app.request( + "http://openbot.test/agent-1/notifications/unmute", + { method: "POST" }, + ); const deleted = await app.request("http://openbot.test/agent-1", { method: "DELETE", }); @@ -300,6 +318,10 @@ describe("agent lifecycle routes", () => { expect(duplicated.status).toBe(201); expect(hidden.status).toBe(204); expect(unhidden.status).toBe(204); + // Two routes rather than one taking a boolean, so a request that arrives twice because the + // network was slow leaves somebody with the state they asked for rather than its opposite. + expect(muted.status).toBe(204); + expect(unmuted.status).toBe(204); expect(deleted.status).toBe(204); expect(store.calls).toEqual([ ["list", actor, false], @@ -309,6 +331,8 @@ describe("agent lifecycle routes", () => { ["duplicate", actor, "agent-1"], ["setHidden", actor, "agent-1", true], ["setHidden", actor, "agent-1", false], + ["setNotificationsMuted", actor, "agent-1", true], + ["setNotificationsMuted", actor, "agent-1", false], ["softDelete", actor, "agent-1"], ]); }); @@ -318,7 +342,13 @@ describe("agent lifecycle routes", () => { async list() { return [ profile(), - profile({ id: "agent-2", ownerUserId: "user-2" }), + // Silenced, so the projection is shown carrying the preference rather than defaulting it. + // Every row reading false would pass just as well against a field that was never read. + profile({ + id: "agent-2", + ownerUserId: "user-2", + notificationsMuted: true, + }), profile({ id: "system-agent", ownerUserId: null, @@ -341,6 +371,7 @@ describe("agent lifecycle routes", () => { avatarSeed: "expense-manager", visibility: "private", hidden: false, + notificationsMuted: false, systemOwned: false, canManage: true, mine: true, @@ -353,6 +384,7 @@ describe("agent lifecycle routes", () => { avatarSeed: "expense-manager", visibility: "private", hidden: false, + notificationsMuted: true, systemOwned: false, canManage: false, mine: false, @@ -365,6 +397,7 @@ describe("agent lifecycle routes", () => { avatarSeed: "expense-manager", visibility: "public", hidden: false, + notificationsMuted: false, systemOwned: true, canManage: false, mine: false, diff --git a/server/tests/notifications.test.ts b/server/tests/notifications.test.ts index 9215cf4..cd22e0f 100644 --- a/server/tests/notifications.test.ts +++ b/server/tests/notifications.test.ts @@ -33,10 +33,19 @@ describe("what is worth interrupting somebody for", () => { expect(isWorthInterrupting("secret_requested")).toBe(true); }); - test("every kind in the vocabulary has an answer", () => { + test("every kind in the vocabulary is framed the way the rule says it should be", () => { expect(notificationKinds.length).toBeGreaterThan(0); for (const kind of notificationKinds) { - expect(typeof isWorthInterrupting(kind)).toBe("boolean"); + const raised = notificationFor({ ...BLOCKED, kind }, HEARD); + + // The rule and the frame are two functions reading one table, and they must not be able to + // disagree: a kind that says it blocks and then produces nothing is a Bot that waits in + // silence, and one that says it does not and produces a card is the interruption this module + // exists to refuse. + expect(raised !== null).toBe(isWorthInterrupting(kind)); + // A kind added with a `blocking` answer and no words is a card that says the Bot's name and + // then stops, which is worse than not showing it at all. + if (raised) expect(raised.headline.trim()).not.toBe(""); } }); @@ -107,6 +116,18 @@ describe("summarize", () => { expect(summarize("Sign in now\u001b[31m.")).toBe("Sign in now [31m."); }); + test("removes the invisible characters that survive a control-character strip", () => { + // A right-to-left override reaches the model from whatever page the Bot is on, and would + // otherwise render a notification that reads backwards from the audit row describing the same + // handover. Zero-width characters go for the same reason: invisible to the person reading the + // sentence, and perfectly visible to whatever reads it after them. + expect(summarize("Sign in \u202eyalpsid\u202c now")).toBe( + "Sign in yalpsid now", + ); + expect(summarize("pass\u200bword\ufeff wanted")).toBe("password wanted"); + expect(summarize("\u200b\u2066\u2069")).toBe(""); + }); + test("clips with an ellipsis at the limit, and leaves shorter text alone", () => { const long = "a".repeat(NOTIFICATION_DETAIL_LIMIT + 40); const clipped = summarize(long);