From 7e11c0d76dffa3746d2fd864bf14dedf19eac717 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:07:21 +0000 Subject: [PATCH 1/9] ref(chat): Persist channel configuration in SQL Co-Authored-By: David Cramer --- .../migrations/0024_channel_configuration.sql | 5 + .../junior/migrations/meta/0024_snapshot.json | 2229 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 7 + .../junior/src/chat/configuration/service.ts | 5 +- packages/junior/src/chat/configuration/sql.ts | 68 + .../junior/src/chat/runtime/thread-state.ts | 58 +- packages/junior/src/db/schema.ts | 3 + .../src/db/schema/channel-configurations.ts | 15 + .../config/channel-configuration-sql.test.ts | 80 + .../component/runtime/thread-state.test.ts | 42 +- .../provider-default-config-behavior.test.ts | 28 +- 11 files changed, 2447 insertions(+), 93 deletions(-) create mode 100644 packages/junior/migrations/0024_channel_configuration.sql create mode 100644 packages/junior/migrations/meta/0024_snapshot.json create mode 100644 packages/junior/src/chat/configuration/sql.ts create mode 100644 packages/junior/src/db/schema/channel-configurations.ts create mode 100644 packages/junior/tests/component/config/channel-configuration-sql.test.ts diff --git a/packages/junior/migrations/0024_channel_configuration.sql b/packages/junior/migrations/0024_channel_configuration.sql new file mode 100644 index 0000000000..ab979a2845 --- /dev/null +++ b/packages/junior/migrations/0024_channel_configuration.sql @@ -0,0 +1,5 @@ +CREATE TABLE "junior_channel_configurations" ( + "channel_id" text PRIMARY KEY NOT NULL, + "configuration_json" jsonb NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); diff --git a/packages/junior/migrations/meta/0024_snapshot.json b/packages/junior/migrations/meta/0024_snapshot.json new file mode 100644 index 0000000000..0657552928 --- /dev/null +++ b/packages/junior/migrations/meta/0024_snapshot.json @@ -0,0 +1,2229 @@ +{ + "id": "a8dbf04f-568a-43b1-a471-2cfd99ed3e68", + "prevId": "86dbef33-04cf-4e3d-b324-b321a3af4af8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": [ + "parent_conversation_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_channel_configurations": { + "name": "junior_channel_configurations", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "configuration_json": { + "name": "configuration_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": [ + "conversation_id", + "plugin", + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": [ + "conversation_id", + "seq" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "actor_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "creator_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "credential_subject_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "root_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_event_tasks": { + "name": "junior_event_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_json": { + "name": "task_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_event_tasks_team_idx": { + "name": "junior_event_tasks_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_event_tasks_match_idx": { + "name": "junior_event_tasks_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_scheduler_runs": { + "name": "junior_scheduler_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_runs_task_status_idx": { + "name": "junior_scheduler_runs_task_status_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_runs_status_idx": { + "name": "junior_scheduler_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_scheduler_tasks": { + "name": "junior_scheduler_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_tasks_creator_idx": { + "name": "junior_scheduler_tasks_creator_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "creator_slack_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_creator_identity_idx": { + "name": "junior_scheduler_tasks_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted' AND \"junior_scheduler_tasks\".\"creator_identity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_team_status_idx": { + "name": "junior_scheduler_tasks_team_status_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_run_now_due_idx": { + "name": "junior_scheduler_tasks_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_next_run_due_idx": { + "name": "junior_scheduler_tasks_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": [ + "date", + "namespace", + "metric", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_task_executions": { + "name": "junior_task_executions", + "schema": "", + "columns": { + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "executed_at_ms": { + "name": "executed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_task_executions_task_time_idx": { + "name": "junior_task_executions_task_time_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_task_executions_time_kind_idx": { + "name": "junior_task_executions_time_kind_idx", + "columns": [ + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_task_executions_conversation_time_idx": { + "name": "junior_task_executions_conversation_time_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_task_executions_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_task_executions_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_task_executions", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_task_executions_kind_namespace_execution_id_pk": { + "name": "junior_task_executions_kind_namespace_execution_id_pk", + "columns": [ + "kind", + "namespace", + "execution_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_task_executions_kind_check": { + "name": "junior_task_executions_kind_check", + "value": "\"junior_task_executions\".\"kind\" in ('scheduled', 'event')" + }, + "junior_task_executions_status_check": { + "name": "junior_task_executions_status_check", + "value": "\"junior_task_executions\".\"status\" in ('blocked', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index dd44aacd1f..1c1517a257 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1786153663118, "tag": "0023_task_title_columns", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1786309318147, + "tag": "0024_channel_configuration", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/junior/src/chat/configuration/service.ts b/packages/junior/src/chat/configuration/service.ts index 05f4bf7ff7..aee247a40b 100644 --- a/packages/junior/src/chat/configuration/service.ts +++ b/packages/junior/src/chat/configuration/service.ts @@ -50,7 +50,8 @@ function sanitizeEntry(value: unknown): ConfigEntry | undefined { }; } -function coerceState(raw: unknown): ChannelConfigState { +/** Coerce persisted channel configuration into the current durable shape. */ +export function coerceChannelConfigState(raw: unknown): ChannelConfigState { if (!isRecord(raw)) { return defaultState(); } @@ -77,7 +78,7 @@ export function createChannelConfigurationService( ): ChannelConfigurationService { const getState = async (): Promise => { const loaded = await storage.load(); - return coerceState(loaded); + return coerceChannelConfigState(loaded); }; const saveState = async (state: ChannelConfigState): Promise => { diff --git a/packages/junior/src/chat/configuration/sql.ts b/packages/junior/src/chat/configuration/sql.ts new file mode 100644 index 0000000000..3457ce2a74 --- /dev/null +++ b/packages/junior/src/chat/configuration/sql.ts @@ -0,0 +1,68 @@ +import { eq } from "drizzle-orm"; +import type { JuniorDatabase } from "@/db/db"; +import { juniorChannelConfigurations } from "@/db/schema"; +import { + coerceChannelConfigState, + createChannelConfigurationService, +} from "@/chat/configuration/service"; +import type { + ChannelConfigState, + ChannelConfigurationService, + ChannelConfigurationStorage, +} from "@/chat/configuration/types"; + +/** Create durable channel configuration storage for one provider channel. */ +function createSqlChannelConfigurationStorage( + db: JuniorDatabase, + channelId: string, +): ChannelConfigurationStorage { + return { + load: async () => { + const rows = await db + .select({ configuration: juniorChannelConfigurations.configuration }) + .from(juniorChannelConfigurations) + .where(eq(juniorChannelConfigurations.channelId, channelId)) + .limit(1); + const configuration = rows[0]?.configuration; + return configuration ? { configuration } : null; + }, + save: async (configuration: ChannelConfigState) => { + const updatedAt = new Date(); + await db + .insert(juniorChannelConfigurations) + .values({ channelId, configuration, updatedAt }) + .onConflictDoUpdate({ + target: juniorChannelConfigurations.channelId, + set: { configuration, updatedAt }, + }); + }, + }; +} + +/** Resolve SQL-owned channel configuration and copy a live legacy record once. */ +export function createDurableChannelConfigurationService(args: { + channelId: string; + db: JuniorDatabase; + loadLegacy: () => Promise; +}): ChannelConfigurationService { + const sqlStorage = createSqlChannelConfigurationStorage( + args.db, + args.channelId, + ); + return createChannelConfigurationService({ + load: async () => { + const durable = await sqlStorage.load(); + if (durable) { + return durable; + } + // TODO(#1267, v0.147.0): Remove after SQL readers have copied all live 7-day Redis records. + const legacyState = coerceChannelConfigState(await args.loadLegacy()); + if (Object.keys(legacyState.entries).length === 0) { + return null; + } + await sqlStorage.save(legacyState); + return { configuration: legacyState }; + }, + save: sqlStorage.save, + }); +} diff --git a/packages/junior/src/chat/runtime/thread-state.ts b/packages/junior/src/chat/runtime/thread-state.ts index c1bf0ffd38..41fe7ad24d 100644 --- a/packages/junior/src/chat/runtime/thread-state.ts +++ b/packages/junior/src/chat/runtime/thread-state.ts @@ -1,7 +1,8 @@ import type { Thread } from "chat"; import { toOptionalString } from "@/chat/coerce"; -import { createChannelConfigurationService } from "@/chat/configuration/service"; +import { createDurableChannelConfigurationService } from "@/chat/configuration/sql"; import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import { getDb } from "@/chat/db"; import { buildConversationStatePatch } from "@/chat/state/conversation"; import type { ThreadConversationState } from "@/chat/state/conversation"; import { persistConversationMessages } from "@/chat/conversations/messages"; @@ -46,7 +47,7 @@ function buildThreadStatePayload( } /** - * Merge a payload into thread or channel scratch with Junior's TTL. + * Merge a payload into thread scratch with Junior's TTL. * * Chat SDK state writes hardcode a 30-day TTL. This boundary owns Junior's * shorter retention policy for the same scratch keys. @@ -157,19 +158,6 @@ export async function getPersistedThreadState( ); } -/** Load the persisted state payload for a channel without constructing a Chat channel. */ -export async function getPersistedChannelState( - channelId: string, -): Promise> { - const stateAdapter = getStateAdapter(); - await stateAdapter.connect(); - return ( - (await stateAdapter.get>( - channelStateKey(channelId), - )) ?? {} - ); -} - /** Persist a thread-state patch by thread id without constructing a Chat thread. */ export async function persistThreadStateById( threadId: string, @@ -191,36 +179,38 @@ export async function persistThreadStateById( ); } -/** Resolve channel configuration from a Chat thread and persist it with Junior's TTL. */ +/** Load legacy Redis-backed channel state during the SQL cutover. */ +async function getLegacyChannelState( + channelId: string, +): Promise> { + const stateAdapter = getStateAdapter(); + await stateAdapter.connect(); + return ( + (await stateAdapter.get>( + channelStateKey(channelId), + )) ?? {} + ); +} + +/** Resolve durable channel configuration from a Chat thread. */ export function getChannelConfigurationService( thread: Thread, ): ChannelConfigurationService { - const channel = thread.channel; const channelId = - toOptionalString(thread.channelId) ?? toOptionalString(channel.id); + toOptionalString(thread.channelId) ?? toOptionalString(thread.channel.id); if (!channelId) { throw new Error("channel id is required to load channel configuration"); } - return createChannelConfigurationService({ - load: async () => await channel.state, - save: async (state) => { - await mergePersistedState(channelStateKey(channelId), { - configuration: state, - }); - }, - }); + return getChannelConfigurationServiceById(channelId); } -/** Resolve a channel configuration service by channel id without a Chat thread. */ +/** Resolve durable channel configuration by channel id without a Chat thread. */ export function getChannelConfigurationServiceById( channelId: string, ): ChannelConfigurationService { - return createChannelConfigurationService({ - load: async () => await getPersistedChannelState(channelId), - save: async (state) => { - await mergePersistedState(channelStateKey(channelId), { - configuration: state, - }); - }, + return createDurableChannelConfigurationService({ + channelId, + db: getDb(), + loadLegacy: async () => await getLegacyChannelState(channelId), }); } diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index c66e3032b8..ce9051f5bf 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -1,3 +1,4 @@ +import { juniorChannelConfigurations } from "./schema/channel-configurations"; import { juniorConversationAnnotations } from "./schema/conversation-annotations"; import { juniorApiTokens } from "./schema/api-tokens"; import { juniorConversationEvents } from "./schema/conversation-events"; @@ -19,6 +20,7 @@ import { import { juniorUsers } from "./schema/users"; export { + juniorChannelConfigurations, juniorConversationAnnotations, juniorApiTokens, juniorAgentBindings, @@ -37,6 +39,7 @@ export { }; export const juniorSqlSchema = { + juniorChannelConfigurations, juniorConversationAnnotations, juniorApiTokens, juniorAgentBindings, diff --git a/packages/junior/src/db/schema/channel-configurations.ts b/packages/junior/src/db/schema/channel-configurations.ts new file mode 100644 index 0000000000..cf28de108b --- /dev/null +++ b/packages/junior/src/db/schema/channel-configurations.ts @@ -0,0 +1,15 @@ +import { jsonb, pgTable, text } from "drizzle-orm/pg-core"; +import type { ChannelConfigState } from "@/chat/configuration/types"; +import { timestamptz } from "./timestamps"; + +/** Durable user configuration scoped to one provider channel. */ +export const juniorChannelConfigurations = pgTable( + "junior_channel_configurations", + { + channelId: text("channel_id").primaryKey(), + configuration: jsonb("configuration_json") + .$type() + .notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, +); diff --git a/packages/junior/tests/component/config/channel-configuration-sql.test.ts b/packages/junior/tests/component/config/channel-configuration-sql.test.ts new file mode 100644 index 0000000000..084b0bbf51 --- /dev/null +++ b/packages/junior/tests/component/config/channel-configuration-sql.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { createDurableChannelConfigurationService } from "@/chat/configuration/sql"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createLocalJuniorSqlFixture } from "../../fixtures/sql"; + +function legacyConfiguration(value: string) { + return { + configuration: { + schemaVersion: 1, + entries: { + "github.repo": { + key: "github.repo", + value, + scope: "conversation", + updatedAt: "2026-08-09T00:00:00.000Z", + }, + }, + }, + }; +} + +describe("SQL channel configuration", () => { + it("persists configuration independently of the legacy cache", async () => { + const fixture = await createLocalJuniorSqlFixture(); + await migrateSchema(fixture.sql); + + try { + const service = createDurableChannelConfigurationService({ + channelId: "C-durable", + db: fixture.sql.db(), + loadLegacy: async () => null, + }); + await service.set({ + key: "github.repo", + value: "getsentry/junior", + updatedBy: "U123", + }); + + const reloaded = createDurableChannelConfigurationService({ + channelId: "C-durable", + db: fixture.sql.db(), + loadLegacy: async () => { + throw new Error("legacy configuration should not be read"); + }, + }); + await expect(reloaded.resolve("github.repo")).resolves.toBe( + "getsentry/junior", + ); + } finally { + await fixture.close(); + } + }); + + it("copies a live legacy record into SQL once", async () => { + const fixture = await createLocalJuniorSqlFixture(); + await migrateSchema(fixture.sql); + + try { + const service = createDurableChannelConfigurationService({ + channelId: "C-cutover", + db: fixture.sql.db(), + loadLegacy: async () => legacyConfiguration("getsentry/legacy"), + }); + await expect(service.resolve("github.repo")).resolves.toBe( + "getsentry/legacy", + ); + + const reloaded = createDurableChannelConfigurationService({ + channelId: "C-cutover", + db: fixture.sql.db(), + loadLegacy: async () => null, + }); + await expect(reloaded.resolve("github.repo")).resolves.toBe( + "getsentry/legacy", + ); + } finally { + await fixture.close(); + } + }); +}); diff --git a/packages/junior/tests/component/runtime/thread-state.test.ts b/packages/junior/tests/component/runtime/thread-state.test.ts index 23197ce498..aaf8cb59d5 100644 --- a/packages/junior/tests/component/runtime/thread-state.test.ts +++ b/packages/junior/tests/component/runtime/thread-state.test.ts @@ -1,8 +1,6 @@ import type { Thread } from "chat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - getChannelConfigurationService, - getPersistedChannelState, getPersistedSandboxState, getPersistedThreadState, persistThreadRuntimeState, @@ -62,12 +60,10 @@ describe("thread sandbox state", () => { expect(getPersistedSandboxState(state)).toBeUndefined(); }); - it("writes thread and channel scratch with Junior's 7-day TTL", async () => { + it("writes thread scratch with Junior's 7-day TTL", async () => { const stateAdapter = getStateAdapter(); const set = vi.spyOn(stateAdapter, "set"); const conversationId = "local:test:thread-scratch-ttl"; - const channelId = "C-scratch-ttl"; - await persistThreadStateById(conversationId, { sandboxRef: { id: "sandbox-ttl" }, }); @@ -80,8 +76,8 @@ describe("thread sandbox state", () => { set.mockClear(); const thread = { id: conversationId, - channelId, - channel: { id: channelId }, + channelId: "C-scratch-ttl", + channel: { id: "C-scratch-ttl" }, } as Thread; await persistThreadRuntimeState(thread, { artifacts: { lastCanvasId: "Fcanvas" }, @@ -94,38 +90,6 @@ describe("thread sandbox state", () => { }), JUNIOR_THREAD_STATE_TTL_MS, ); - - set.mockClear(); - await getChannelConfigurationService(thread).set({ - key: "github.repo", - value: "getsentry/junior", - updatedBy: "U123", - }); - expect(set).toHaveBeenCalledWith( - `channel-state:${channelId}`, - expect.objectContaining({ - configuration: expect.objectContaining({ - entries: expect.objectContaining({ - "github.repo": expect.objectContaining({ - key: "github.repo", - value: "getsentry/junior", - }), - }), - }), - }), - JUNIOR_THREAD_STATE_TTL_MS, - ); - - await expect(getPersistedChannelState(channelId)).resolves.toMatchObject({ - configuration: { - entries: { - "github.repo": { - key: "github.repo", - value: "getsentry/junior", - }, - }, - }, - }); }); }); diff --git a/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts b/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts index ace83980e0..4f58d81658 100644 --- a/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts +++ b/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts @@ -6,7 +6,7 @@ import { createTestDestination, } from "../../fixtures/slack-harness"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; -import { getPersistedChannelState } from "@/chat/runtime/thread-state"; +import { getChannelConfigurationServiceById } from "@/chat/runtime/thread-state"; import { deliverAssistantMessagesForTest } from "../../fixtures/agent-runner"; function toPostedText(value: unknown): string { @@ -50,16 +50,12 @@ describe("Slack behavior: provider default configuration", () => { expect(executeAgentRun).not.toHaveBeenCalled(); expect(thread.posts).toHaveLength(1); expect(toPostedText(thread.posts[0])).toContain("getsentry/junior"); - await expect(getPersistedChannelState(thread.channelId)).resolves.toMatchObject({ - configuration: { - entries: { - "github.repo": { - key: "github.repo", - value: "getsentry/junior", - source: "provider-default-config", - }, - }, - }, + await expect( + getChannelConfigurationServiceById(thread.channelId).get("github.repo"), + ).resolves.toMatchObject({ + key: "github.repo", + value: "getsentry/junior", + source: "provider-default-config", }); }); @@ -105,12 +101,8 @@ describe("Slack behavior: provider default configuration", () => { expect(executeAgentRun).toHaveBeenCalledOnce(); expect(toPostedText(thread.posts[0])).toContain("Created the issue."); - await expect(getPersistedChannelState(thread.channelId)).resolves.not.toMatchObject({ - configuration: { - entries: { - "github.repo": expect.anything(), - }, - }, - }); + await expect( + getChannelConfigurationServiceById(thread.channelId).get("github.repo"), + ).resolves.toBeUndefined(); }); }); From 9930283438451fa8a8528b528d87763e10ab3cc0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:12:09 +0000 Subject: [PATCH 2/9] test(chat): Bump scheduler migration count for channel config --- packages/junior/tests/component/scheduled-tasks-sql.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/junior/tests/component/scheduled-tasks-sql.test.ts b/packages/junior/tests/component/scheduled-tasks-sql.test.ts index 7df72501f7..cc00cfd011 100644 --- a/packages/junior/tests/component/scheduled-tasks-sql.test.ts +++ b/packages/junior/tests/component/scheduled-tasks-sql.test.ts @@ -175,7 +175,7 @@ describe("scheduled-task SQL storage", () => { await expect(migrateSchema(fixture.sql)).resolves.toMatchObject({ existing: 16, - migrated: 8, + migrated: 9, }); const [migrated] = await fixture.sql.query<{ creatorIdentityId: string | null; From 8c68c410c909ec01251f5529f724f0eafab94709 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:23:13 +0000 Subject: [PATCH 3/9] ref(chat): Key durable configuration by destination Store user configuration under destinationKey instead of bare channelId so Slack and future providers share one durable identity. Keep a one-time Redis channel-state cutover for live Slack records. Co-Authored-By: David Cramer --- .../migrations/0024_channel_configuration.sql | 5 -- .../0024_destination_configuration.sql | 5 ++ .../junior/migrations/meta/0024_snapshot.json | 64 +++++++++---------- packages/junior/migrations/meta/_journal.json | 6 +- .../junior/src/chat/agent-dispatch/types.ts | 4 +- packages/junior/src/chat/agent/index.ts | 4 +- packages/junior/src/chat/agent/request.ts | 4 +- packages/junior/src/chat/agent/sandbox.ts | 6 +- packages/junior/src/chat/agent/tools.ts | 2 +- packages/junior/src/chat/app/factory.ts | 4 +- .../src/chat/capabilities/jr-rpc-command.ts | 14 ++-- .../junior/src/chat/configuration/service.ts | 40 +++++++----- packages/junior/src/chat/configuration/sql.ts | 54 ++++++++-------- .../junior/src/chat/configuration/types.ts | 10 +-- .../junior/src/chat/runtime/reply-executor.ts | 7 +- .../junior/src/chat/runtime/slack-resume.ts | 12 ++-- .../junior/src/chat/runtime/slack-runtime.ts | 1 + .../junior/src/chat/runtime/thread-state.ts | 40 ++++++------ .../junior/src/chat/runtime/turn-input.ts | 6 +- .../src/chat/runtime/turn-preparation.ts | 15 +++-- .../chat/services/provider-default-config.ts | 8 +-- .../junior/src/chat/slack/dispatch-turn.ts | 10 +-- .../src/chat/task-execution/paused-turn.ts | 9 ++- packages/junior/src/db/schema.ts | 6 +- .../src/db/schema/channel-configurations.ts | 15 ----- .../db/schema/destination-configurations.ts | 15 +++++ .../junior/src/handlers/mcp-oauth-callback.ts | 9 ++- .../junior/src/handlers/oauth-callback.ts | 9 ++- ... => destination-configuration-sql.test.ts} | 37 ++++++++--- .../integration/agent-continue-slack.test.ts | 16 +++-- .../provider-default-config-behavior.test.ts | 10 ++- .../unit/config/configuration-service.test.ts | 10 +-- .../unit/handlers/jr-rpc-command.test.ts | 28 ++++---- 33 files changed, 257 insertions(+), 228 deletions(-) delete mode 100644 packages/junior/migrations/0024_channel_configuration.sql create mode 100644 packages/junior/migrations/0024_destination_configuration.sql delete mode 100644 packages/junior/src/db/schema/channel-configurations.ts create mode 100644 packages/junior/src/db/schema/destination-configurations.ts rename packages/junior/tests/component/config/{channel-configuration-sql.test.ts => destination-configuration-sql.test.ts} (65%) diff --git a/packages/junior/migrations/0024_channel_configuration.sql b/packages/junior/migrations/0024_channel_configuration.sql deleted file mode 100644 index ab979a2845..0000000000 --- a/packages/junior/migrations/0024_channel_configuration.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE "junior_channel_configurations" ( - "channel_id" text PRIMARY KEY NOT NULL, - "configuration_json" jsonb NOT NULL, - "updated_at" timestamp with time zone NOT NULL -); diff --git a/packages/junior/migrations/0024_destination_configuration.sql b/packages/junior/migrations/0024_destination_configuration.sql new file mode 100644 index 0000000000..74124011bc --- /dev/null +++ b/packages/junior/migrations/0024_destination_configuration.sql @@ -0,0 +1,5 @@ +CREATE TABLE "junior_destination_configurations" ( + "destination_key" text PRIMARY KEY NOT NULL, + "configuration_json" jsonb NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); diff --git a/packages/junior/migrations/meta/0024_snapshot.json b/packages/junior/migrations/meta/0024_snapshot.json index 0657552928..b17096d04d 100644 --- a/packages/junior/migrations/meta/0024_snapshot.json +++ b/packages/junior/migrations/meta/0024_snapshot.json @@ -1,5 +1,5 @@ { - "id": "a8dbf04f-568a-43b1-a471-2cfd99ed3e68", + "id": "12898d5c-be75-48cf-a404-b8ce24dc69a7", "prevId": "86dbef33-04cf-4e3d-b324-b321a3af4af8", "version": "7", "dialect": "postgresql", @@ -369,37 +369,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.junior_channel_configurations": { - "name": "junior_channel_configurations", - "schema": "", - "columns": { - "channel_id": { - "name": "channel_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "configuration_json": { - "name": "configuration_json", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.junior_conversation_annotations": { "name": "junior_conversation_annotations", "schema": "", @@ -1175,6 +1144,37 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.junior_destination_configurations": { + "name": "junior_destination_configurations", + "schema": "", + "columns": { + "destination_key": { + "name": "destination_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "configuration_json": { + "name": "configuration_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.junior_destinations": { "name": "junior_destinations", "schema": "", diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 1c1517a257..76a97abc1a 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -173,9 +173,9 @@ { "idx": 24, "version": "7", - "when": 1786309318147, - "tag": "0024_channel_configuration", + "when": 1786310479611, + "tag": "0024_destination_configuration", "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/junior/src/chat/agent-dispatch/types.ts b/packages/junior/src/chat/agent-dispatch/types.ts index 58d98e6bd5..08d51c4017 100644 --- a/packages/junior/src/chat/agent-dispatch/types.ts +++ b/packages/junior/src/chat/agent-dispatch/types.ts @@ -12,7 +12,7 @@ import type { } from "@/chat/credentials/context"; import type { AgentRunRouting } from "@/chat/agent/request"; import type { AgentTurnSurface } from "@/chat/task-execution/checkpoint"; -import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import type { DestinationConfigurationService } from "@/chat/configuration/types"; export type DispatchStatus = | "pending" @@ -80,7 +80,7 @@ export interface DispatchTurnResult { /** Dispatch-owned authority supplied to the shared turn runtime. */ export interface DispatchTurnContext { disabledFeatures: readonly ["interactive-auth"]; - channelConfiguration: ChannelConfigurationService; + destinationConfiguration: DestinationConfigurationService; credentialContext: CredentialContext; destinationVisibility: DestinationVisibility; dispatch: NonNullable; diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index ab789d3374..a4c43e698e 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -449,8 +449,8 @@ async function executeAgentRunInPrivacyContext( }); } }; - const persistedConfigurationValues = policy.channelConfiguration - ? await policy.channelConfiguration.resolveValues() + const persistedConfigurationValues = policy.destinationConfiguration + ? await policy.destinationConfiguration.resolveValues() : {}; const configurationValues: Record = { ...getConfigDefaults(), diff --git a/packages/junior/src/chat/agent/request.ts b/packages/junior/src/chat/agent/request.ts index 13016ead88..7b5ede27c5 100644 --- a/packages/junior/src/chat/agent/request.ts +++ b/packages/junior/src/chat/agent/request.ts @@ -13,7 +13,7 @@ import type { Source, SystemActor, } from "@sentry/junior-plugin-api"; -import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import type { DestinationConfigurationService } from "@/chat/configuration/types"; import type { ConversationPrivacy } from "@/chat/conversation-privacy"; import type { CredentialContext } from "@/chat/credentials/context"; import type { PiMessage } from "@/chat/pi/messages"; @@ -164,7 +164,7 @@ export interface AgentRunPolicy { /** Explicit per-agent reasoning level. When set, adaptive routing is disabled. */ reasoningLevel?: TurnReasoningLevel; configuration?: Record; - channelConfiguration?: ChannelConfigurationService; + destinationConfiguration?: DestinationConfigurationService; skillDirs?: string[]; /** Per-slice override for app-owned sandbox egress trace propagation. */ sandboxTracePropagation?: SandboxEgressTracePropagationConfig; diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index 549068cdd9..f034aeb6fb 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -9,7 +9,7 @@ import type { FileUpload } from "chat"; import type { PluginSandbox } from "@sentry/junior-plugin-api"; import { isUserActor, type Actor } from "@/chat/actor"; import { maybeExecuteJrRpcCustomCommand } from "@/chat/capabilities/jr-rpc-command"; -import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import type { DestinationConfigurationService } from "@/chat/configuration/types"; import type { CredentialContext } from "@/chat/credentials/context"; import { listReferenceFiles } from "@/chat/discovery"; import type { LogContext } from "@/chat/logging"; @@ -35,7 +35,7 @@ export interface AgentSandboxOptions { egressSignals?: SandboxEgressSignalTransport; credentialEgress?: CredentialContext; actor?: Actor; - channelConfiguration?: ChannelConfigurationService; + destinationConfiguration?: DestinationConfigurationService; configurationValues: Record; getActiveSkill(): Skill | null; prepareSandbox(workspace: SandboxWorkspace): void | Promise; @@ -165,7 +165,7 @@ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { if (command) { const result = await maybeExecuteJrRpcCustomCommand(command, { activeSkill: options.getActiveSkill(), - channelConfiguration: options.channelConfiguration, + destinationConfiguration: options.destinationConfiguration, actorId: isUserActor(options.actor) ? options.actor.userId : undefined, diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index c5bf79f7c0..c9afd18ae6 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -182,7 +182,7 @@ export async function wireAgentTools( egressSignals: args.policy.sandboxEgressSignals, credentialEgress: args.routing.credentialContext, actor: args.currentActor, - channelConfiguration: args.policy.channelConfiguration, + destinationConfiguration: args.policy.destinationConfiguration, configurationValues: args.configurationValues, getActiveSkill: () => args.skillSandbox.getActiveSkill(), prepareSandbox: pluginHooks.prepareSandbox, diff --git a/packages/junior/src/chat/app/factory.ts b/packages/junior/src/chat/app/factory.ts index d0cf1df9ff..1e348cf7ba 100644 --- a/packages/junior/src/chat/app/factory.ts +++ b/packages/junior/src/chat/app/factory.ts @@ -21,7 +21,7 @@ import { stripLeadingBotMention, } from "@/chat/runtime/thread-context"; import { - getChannelConfigurationServiceById, + getDestinationConfigurationService, getPersistedThreadState, mergeArtifactsState, persistThreadState, @@ -241,7 +241,7 @@ export function createSlackRuntime(options: CreateSlackRuntimeOptions) { return { ...runtime, runDispatchTurn: createSlackDispatchTurnRunner({ - getChannelConfiguration: getChannelConfigurationServiceById, + getDestinationConfiguration: getDestinationConfigurationService, getSlackAdapter: options.getSlackAdapter, replyToThread, }), diff --git a/packages/junior/src/chat/capabilities/jr-rpc-command.ts b/packages/junior/src/chat/capabilities/jr-rpc-command.ts index 6b638ca669..f021ca5421 100644 --- a/packages/junior/src/chat/capabilities/jr-rpc-command.ts +++ b/packages/junior/src/chat/capabilities/jr-rpc-command.ts @@ -1,12 +1,12 @@ import { Bash, defineCommand } from "just-bash"; -import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import type { DestinationConfigurationService } from "@/chat/configuration/types"; import { logInfo } from "@/chat/logging"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; import type { Skill } from "@/chat/skills"; type JrRpcDeps = { activeSkill: Skill | null; - channelConfiguration?: ChannelConfigurationService; + destinationConfiguration?: DestinationConfigurationService; actorId?: string; onConfigurationValueChanged?: ( key: string, @@ -32,13 +32,13 @@ function commandResult(input: { }; } -function requireChannelConfiguration( +function requireDestinationConfiguration( deps: JrRpcDeps, ): - | { ok: true; configuration: ChannelConfigurationService } + | { ok: true; configuration: DestinationConfigurationService } | { ok: false; result: ReturnType } { - if (deps.channelConfiguration) { - return { ok: true, configuration: deps.channelConfiguration }; + if (deps.destinationConfiguration) { + return { ok: true, configuration: deps.destinationConfiguration }; } return { ok: false, @@ -100,7 +100,7 @@ async function handleConfigCommand( "jr-rpc config list [--prefix ]", ].join("\n"); const subverb = (args[0] ?? "").trim(); - const configurationResult = requireChannelConfiguration(deps); + const configurationResult = requireDestinationConfiguration(deps); if (!configurationResult.ok) { return configurationResult.result; } diff --git a/packages/junior/src/chat/configuration/service.ts b/packages/junior/src/chat/configuration/service.ts index aee247a40b..0e3e84ef59 100644 --- a/packages/junior/src/chat/configuration/service.ts +++ b/packages/junior/src/chat/configuration/service.ts @@ -1,7 +1,7 @@ import type { - ChannelConfigState, - ChannelConfigurationService, - ChannelConfigurationStorage, + DestinationConfigState, + DestinationConfigurationService, + DestinationConfigurationStorage, ConfigEntry, } from "@/chat/configuration/types"; import { @@ -10,7 +10,7 @@ import { } from "@/chat/configuration/validation"; import { isRecord, toOptionalString } from "@/chat/coerce"; -function defaultState(): ChannelConfigState { +function defaultState(): DestinationConfigState { return { schemaVersion: 1, entries: {}, @@ -33,11 +33,15 @@ function sanitizeEntry(value: unknown): ConfigEntry | undefined { if (!updatedAt) { return undefined; } - // Accept both legacy "channel" and current "conversation" scope from persisted data - if (value.scope !== "channel" && value.scope !== "conversation") { + // Accept legacy "channel" / "conversation" scopes from Redis and early SQL rows. + if ( + value.scope !== "channel" && + value.scope !== "conversation" && + value.scope !== "destination" + ) { return undefined; } - const scope = "conversation" as const; + const scope = "destination" as const; return { key, @@ -50,8 +54,10 @@ function sanitizeEntry(value: unknown): ConfigEntry | undefined { }; } -/** Coerce persisted channel configuration into the current durable shape. */ -export function coerceChannelConfigState(raw: unknown): ChannelConfigState { +/** Coerce persisted destination configuration into the current durable shape. */ +export function coerceDestinationConfigState( + raw: unknown, +): DestinationConfigState { if (!isRecord(raw)) { return defaultState(); } @@ -73,15 +79,15 @@ export function coerceChannelConfigState(raw: unknown): ChannelConfigState { }; } -export function createChannelConfigurationService( - storage: ChannelConfigurationStorage, -): ChannelConfigurationService { - const getState = async (): Promise => { +export function createDestinationConfigurationService( + storage: DestinationConfigurationStorage, +): DestinationConfigurationService { + const getState = async (): Promise => { const loaded = await storage.load(); - return coerceChannelConfigState(loaded); + return coerceDestinationConfigState(loaded); }; - const saveState = async (state: ChannelConfigState): Promise => { + const saveState = async (state: DestinationConfigState): Promise => { await storage.save({ schemaVersion: 1, entries: state.entries, @@ -94,7 +100,7 @@ export function createChannelConfigurationService( return state.entries[normalizedKey]; }; - const set: ChannelConfigurationService["set"] = async (input) => { + const set: DestinationConfigurationService["set"] = async (input) => { const normalizedKey = input.key.trim(); const keyError = validateConfigKey(normalizedKey); if (keyError) { @@ -110,7 +116,7 @@ export function createChannelConfigurationService( const nextEntry: ConfigEntry = { key: normalizedKey, value: input.value, - scope: "conversation", + scope: "destination", updatedAt: new Date().toISOString(), updatedBy: toOptionalString(input.updatedBy), source: toOptionalString(input.source), diff --git a/packages/junior/src/chat/configuration/sql.ts b/packages/junior/src/chat/configuration/sql.ts index 3457ce2a74..9fd4eb7333 100644 --- a/packages/junior/src/chat/configuration/sql.ts +++ b/packages/junior/src/chat/configuration/sql.ts @@ -1,62 +1,62 @@ +import type { Destination } from "@sentry/junior-plugin-api"; import { eq } from "drizzle-orm"; import type { JuniorDatabase } from "@/db/db"; -import { juniorChannelConfigurations } from "@/db/schema"; +import { juniorDestinationConfigurations } from "@/db/schema"; +import { destinationKey } from "@/chat/destination"; import { - coerceChannelConfigState, - createChannelConfigurationService, + coerceDestinationConfigState, + createDestinationConfigurationService, } from "@/chat/configuration/service"; import type { - ChannelConfigState, - ChannelConfigurationService, - ChannelConfigurationStorage, + DestinationConfigState, + DestinationConfigurationService, + DestinationConfigurationStorage, } from "@/chat/configuration/types"; -/** Create durable channel configuration storage for one provider channel. */ -function createSqlChannelConfigurationStorage( +/** Create durable destination configuration storage for one destination key. */ +function createSqlDestinationConfigurationStorage( db: JuniorDatabase, - channelId: string, -): ChannelConfigurationStorage { + key: string, +): DestinationConfigurationStorage { return { load: async () => { const rows = await db - .select({ configuration: juniorChannelConfigurations.configuration }) - .from(juniorChannelConfigurations) - .where(eq(juniorChannelConfigurations.channelId, channelId)) + .select({ configuration: juniorDestinationConfigurations.configuration }) + .from(juniorDestinationConfigurations) + .where(eq(juniorDestinationConfigurations.destinationKey, key)) .limit(1); const configuration = rows[0]?.configuration; return configuration ? { configuration } : null; }, - save: async (configuration: ChannelConfigState) => { + save: async (configuration: DestinationConfigState) => { const updatedAt = new Date(); await db - .insert(juniorChannelConfigurations) - .values({ channelId, configuration, updatedAt }) + .insert(juniorDestinationConfigurations) + .values({ destinationKey: key, configuration, updatedAt }) .onConflictDoUpdate({ - target: juniorChannelConfigurations.channelId, + target: juniorDestinationConfigurations.destinationKey, set: { configuration, updatedAt }, }); }, }; } -/** Resolve SQL-owned channel configuration and copy a live legacy record once. */ -export function createDurableChannelConfigurationService(args: { - channelId: string; +/** Resolve SQL-owned destination configuration and copy a live legacy record once. */ +export function createDurableDestinationConfigurationService(args: { + destination: Destination; db: JuniorDatabase; loadLegacy: () => Promise; -}): ChannelConfigurationService { - const sqlStorage = createSqlChannelConfigurationStorage( - args.db, - args.channelId, - ); - return createChannelConfigurationService({ +}): DestinationConfigurationService { + const key = destinationKey(args.destination); + const sqlStorage = createSqlDestinationConfigurationStorage(args.db, key); + return createDestinationConfigurationService({ load: async () => { const durable = await sqlStorage.load(); if (durable) { return durable; } // TODO(#1267, v0.147.0): Remove after SQL readers have copied all live 7-day Redis records. - const legacyState = coerceChannelConfigState(await args.loadLegacy()); + const legacyState = coerceDestinationConfigState(await args.loadLegacy()); if (Object.keys(legacyState.entries).length === 0) { return null; } diff --git a/packages/junior/src/chat/configuration/types.ts b/packages/junior/src/chat/configuration/types.ts index 6f04591ac1..f10d97ee0f 100644 --- a/packages/junior/src/chat/configuration/types.ts +++ b/packages/junior/src/chat/configuration/types.ts @@ -1,4 +1,4 @@ -export type ConfigScope = "conversation"; +export type ConfigScope = "destination"; export interface ConfigEntry { key: string; @@ -10,17 +10,17 @@ export interface ConfigEntry { expiresAt?: string; } -export interface ChannelConfigState { +export interface DestinationConfigState { schemaVersion: 1; entries: Record; } -export interface ChannelConfigurationStorage { +export interface DestinationConfigurationStorage { load: () => Promise; - save: (state: ChannelConfigState) => Promise; + save: (state: DestinationConfigState) => Promise; } -export interface ChannelConfigurationService { +export interface DestinationConfigurationService { get: (key: string) => Promise; set: (input: { key: string; diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index b8e38b829c..70541f6a23 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -580,7 +580,8 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { options.explicitMention || message.isMention, ), queuedMessages: options.queuedMessages, - channelConfiguration: options.execution?.channelConfiguration, + destination, + destinationConfiguration: options.execution?.destinationConfiguration, context: { threadId, actorId: slackActorId, @@ -871,7 +872,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { const configReply = options.execution?.skipProviderDefaultConfig ? undefined : await maybeApplyProviderDefaultConfigRequest({ - channelConfiguration: preparedState.channelConfiguration, + destinationConfiguration: preparedState.destinationConfiguration, actorId: actor?.userId, text: effectiveUserText, }); @@ -1387,7 +1388,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { }, policy: { configuration: preparedState.configuration, - channelConfiguration: preparedState.channelConfiguration, + destinationConfiguration: preparedState.destinationConfiguration, disabledFeatures: options.execution?.disabledFeatures ?? (message.author.isBot === true diff --git a/packages/junior/src/chat/runtime/slack-resume.ts b/packages/junior/src/chat/runtime/slack-resume.ts index e301f9a9ab..a728b26dfc 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -8,7 +8,7 @@ import type { ReplyAttribution } from "@sentry/junior-plugin-api"; import { botConfig } from "@/chat/config"; import { standardModelId } from "@/chat/model-profile"; -import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import type { DestinationConfigurationService } from "@/chat/configuration/types"; import { RetryableDeliveryError, type AgentRunRequest, @@ -118,11 +118,11 @@ async function postSlackMessageBestEffort( /** Create a read-only configuration service from persisted values. */ function createReadOnlyConfigService( values: Record, -): ChannelConfigurationService { +): DestinationConfigurationService { const entries = Object.entries(values).map(([key, value]) => ({ key, value, - scope: "conversation" as const, + scope: "destination" as const, updatedAt: new Date().toISOString(), })); @@ -352,8 +352,8 @@ function createResumeReplyContext( const requestDeadline = getTurnRequestDeadline(); const threadId = args.lockKey ?? getDefaultLockKey(args.channelId, args.threadTs); - const persistedChannelConfiguration = - replyContext.policy?.channelConfiguration ?? + const persistedDestinationConfiguration = + replyContext.policy?.destinationConfiguration ?? (replyContext.policy?.configuration ? createReadOnlyConfigService(replyContext.policy.configuration) : undefined); @@ -380,7 +380,7 @@ function createResumeReplyContext( ...replyContext.policy, turnDeadlineAtMs: replyContext.policy?.turnDeadlineAtMs ?? requestDeadline?.deadlineAtMs, - channelConfiguration: persistedChannelConfiguration, + destinationConfiguration: persistedDestinationConfiguration, }, state: replyContext.state, observers: { diff --git a/packages/junior/src/chat/runtime/slack-runtime.ts b/packages/junior/src/chat/runtime/slack-runtime.ts index c23f2353de..07cf36ef46 100644 --- a/packages/junior/src/chat/runtime/slack-runtime.ts +++ b/packages/junior/src/chat/runtime/slack-runtime.ts @@ -974,6 +974,7 @@ export function createSlackTurnRuntime< message, text: currentText, explicitMention: Boolean(message.isMention), + destination: hooks.destination, context: threadContext, queuedMessages, }); diff --git a/packages/junior/src/chat/runtime/thread-state.ts b/packages/junior/src/chat/runtime/thread-state.ts index 41fe7ad24d..4c5eba07b5 100644 --- a/packages/junior/src/chat/runtime/thread-state.ts +++ b/packages/junior/src/chat/runtime/thread-state.ts @@ -1,7 +1,8 @@ import type { Thread } from "chat"; +import type { Destination } from "@sentry/junior-plugin-api"; import { toOptionalString } from "@/chat/coerce"; -import { createDurableChannelConfigurationService } from "@/chat/configuration/sql"; -import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import { createDurableDestinationConfigurationService } from "@/chat/configuration/sql"; +import type { DestinationConfigurationService } from "@/chat/configuration/types"; import { getDb } from "@/chat/db"; import { buildConversationStatePatch } from "@/chat/state/conversation"; import type { ThreadConversationState } from "@/chat/state/conversation"; @@ -192,25 +193,22 @@ async function getLegacyChannelState( ); } -/** Resolve durable channel configuration from a Chat thread. */ -export function getChannelConfigurationService( - thread: Thread, -): ChannelConfigurationService { - const channelId = - toOptionalString(thread.channelId) ?? toOptionalString(thread.channel.id); - if (!channelId) { - throw new Error("channel id is required to load channel configuration"); - } - return getChannelConfigurationServiceById(channelId); -} - -/** Resolve durable channel configuration by channel id without a Chat thread. */ -export function getChannelConfigurationServiceById( - channelId: string, -): ChannelConfigurationService { - return createDurableChannelConfigurationService({ - channelId, +/** + * Resolve durable destination configuration. + * + * Slack still reads the old channel-id Redis bag once during cutover. + */ +export function getDestinationConfigurationService( + destination: Destination, +): DestinationConfigurationService { + return createDurableDestinationConfigurationService({ + destination, db: getDb(), - loadLegacy: async () => await getLegacyChannelState(channelId), + loadLegacy: async () => { + if (destination.platform !== "slack") { + return null; + } + return await getLegacyChannelState(destination.channelId); + }, }); } diff --git a/packages/junior/src/chat/runtime/turn-input.ts b/packages/junior/src/chat/runtime/turn-input.ts index 3b44bd2e33..9aaf525908 100644 --- a/packages/junior/src/chat/runtime/turn-input.ts +++ b/packages/junior/src/chat/runtime/turn-input.ts @@ -1,5 +1,6 @@ import type { Message, Thread } from "chat"; -import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import type { Destination } from "@sentry/junior-plugin-api"; +import type { DestinationConfigurationService } from "@/chat/configuration/types"; export interface TurnContext { channelId?: string; @@ -24,7 +25,8 @@ export interface QueuedTurnMessage extends TurnMessageText { } export interface PrepareTurnStateInput { - channelConfiguration?: ChannelConfigurationService; + destination: Destination; + destinationConfiguration?: DestinationConfigurationService; context: TurnContext; explicitMention: boolean; message: Message; diff --git a/packages/junior/src/chat/runtime/turn-preparation.ts b/packages/junior/src/chat/runtime/turn-preparation.ts index 42865ec671..123663e0d6 100644 --- a/packages/junior/src/chat/runtime/turn-preparation.ts +++ b/packages/junior/src/chat/runtime/turn-preparation.ts @@ -31,7 +31,7 @@ import { isVisionEnabled, } from "@/chat/slack/vision-context"; import { - getChannelConfigurationService, + getDestinationConfigurationService, getPersistedSandboxState, } from "@/chat/runtime/thread-state"; import { @@ -39,7 +39,7 @@ import { persistConversationMessages, } from "@/chat/conversations/messages"; import { persistConversationMessageSummaries } from "@/chat/conversations/message-summaries"; -import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import type { DestinationConfigurationService } from "@/chat/configuration/types"; import { appendSlackLegacyAttachmentText } from "@/chat/slack/legacy-attachments"; import { getSlackMessageText } from "@/chat/slack/message"; import type { @@ -53,7 +53,7 @@ const BACKFILL_MESSAGE_LIMIT = 80; export interface PreparedTurnState { artifacts: ThreadArtifactsState; configuration?: Record; - channelConfiguration?: ChannelConfigurationService; + destinationConfiguration?: DestinationConfigurationService; conversation: ThreadConversationState; conversationContext?: string; sandboxRef?: SandboxRef; @@ -173,9 +173,10 @@ export function createPrepareTurnState(deps: PrepareTurnStateDeps) { const conversation = coerceThreadConversationState(existingState); const conversationId = args.context.threadId ?? args.context.runId; await hydrateConversationMessages({ conversation, conversationId }); - const channelConfiguration = - args.channelConfiguration ?? getChannelConfigurationService(args.thread); - const configuration = await channelConfiguration.resolveValues(); + const destinationConfiguration = + args.destinationConfiguration ?? + getDestinationConfigurationService(args.destination); + const configuration = await destinationConfiguration.resolveValues(); const backfillSource = args.skipBackfill ? undefined @@ -258,7 +259,7 @@ export function createPrepareTurnState(deps: PrepareTurnStateDeps) { return { artifacts, configuration, - channelConfiguration, + destinationConfiguration, conversation, sandboxRef, conversationContext, diff --git a/packages/junior/src/chat/services/provider-default-config.ts b/packages/junior/src/chat/services/provider-default-config.ts index 9607ddfb3a..afa2a5d39b 100644 --- a/packages/junior/src/chat/services/provider-default-config.ts +++ b/packages/junior/src/chat/services/provider-default-config.ts @@ -1,4 +1,4 @@ -import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import type { DestinationConfigurationService } from "@/chat/configuration/types"; const GITHUB_REPO_PART = String.raw`[A-Za-z0-9_.-]*[A-Za-z0-9_-]`; const GITHUB_REPO_RE = new RegExp( @@ -8,17 +8,17 @@ const GITHUB_REPO_RE = new RegExp( /** Apply explicit provider-default config requests that do not need agent reasoning. */ export async function maybeApplyProviderDefaultConfigRequest(args: { - channelConfiguration?: ChannelConfigurationService; + destinationConfiguration?: DestinationConfigurationService; actorId?: string; text: string; }): Promise<{ text: string } | null> { const match = GITHUB_REPO_RE.exec(args.text); const repo = match?.[1]; - if (!repo || !args.channelConfiguration) { + if (!repo || !args.destinationConfiguration) { return null; } - await args.channelConfiguration.set({ + await args.destinationConfiguration.set({ key: "github.repo", value: repo, updatedBy: args.actorId, diff --git a/packages/junior/src/chat/slack/dispatch-turn.ts b/packages/junior/src/chat/slack/dispatch-turn.ts index 19f1bfe031..a3d2823c21 100644 --- a/packages/junior/src/chat/slack/dispatch-turn.ts +++ b/packages/junior/src/chat/slack/dispatch-turn.ts @@ -32,9 +32,9 @@ interface DispatchReplyToThread { /** Build the Slack provider adapter for agent-dispatched conversation turns. */ export function createSlackDispatchTurnRunner(options: { - getChannelConfiguration: ( - channelId: string, - ) => DispatchTurnContext["channelConfiguration"]; + getDestinationConfiguration: ( + destination: DispatchRecord["destination"], + ) => DispatchTurnContext["destinationConfiguration"]; getSlackAdapter: () => SlackAdapter; replyToThread: DispatchReplyToThread; }) { @@ -95,8 +95,8 @@ export function createSlackDispatchTurnRunner(options: { destination: dispatch.destination, execution: { disabledFeatures: ["interactive-auth"], - channelConfiguration: options.getChannelConfiguration( - dispatch.destination.channelId, + destinationConfiguration: options.getDestinationConfiguration( + dispatch.destination, ), credentialContext: routing.credentialContext, destinationVisibility: dispatch.destinationVisibility, diff --git a/packages/junior/src/chat/task-execution/paused-turn.ts b/packages/junior/src/chat/task-execution/paused-turn.ts index d1627a61ed..1f76b626d1 100644 --- a/packages/junior/src/chat/task-execution/paused-turn.ts +++ b/packages/junior/src/chat/task-execution/paused-turn.ts @@ -31,7 +31,7 @@ import { getPersistedThreadState, getPersistedSandboxState, persistThreadStateById, - getChannelConfigurationServiceById, + getDestinationConfigurationService, } from "@/chat/runtime/thread-state"; import { buildDeliveredTurnStatePatch } from "@/chat/runtime/delivered-turn-state"; import { @@ -473,9 +473,8 @@ async function runPausedTurnInContext( } const { actor, credentialContext } = identity; - const channelConfiguration = getChannelConfigurationServiceById( - destination.channelId, - ); + const destinationConfiguration = + getDestinationConfigurationService(destination); const conversationContext = dispatchId ? undefined : buildConversationContext(conversation, { @@ -544,7 +543,7 @@ async function runPausedTurnInContext( artifacts.assistantContextChannelId ?? destination.channelId, }, policy: { - channelConfiguration, + destinationConfiguration, }, state: { artifactState: artifacts, diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index ce9051f5bf..8499660bbe 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -1,4 +1,3 @@ -import { juniorChannelConfigurations } from "./schema/channel-configurations"; import { juniorConversationAnnotations } from "./schema/conversation-annotations"; import { juniorApiTokens } from "./schema/api-tokens"; import { juniorConversationEvents } from "./schema/conversation-events"; @@ -8,6 +7,7 @@ import { juniorAgentBindings, juniorAgentInvocations, } from "./schema/agent-invocations"; +import { juniorDestinationConfigurations } from "./schema/destination-configurations"; import { juniorDestinations } from "./schema/destinations"; import { juniorEventTasks } from "./schema/event-tasks"; import { juniorIdentities } from "./schema/identities"; @@ -20,7 +20,6 @@ import { import { juniorUsers } from "./schema/users"; export { - juniorChannelConfigurations, juniorConversationAnnotations, juniorApiTokens, juniorAgentBindings, @@ -28,6 +27,7 @@ export { juniorConversationEvents, juniorConversationBindings, juniorConversations, + juniorDestinationConfigurations, juniorDestinations, juniorEventTasks, juniorIdentities, @@ -39,7 +39,6 @@ export { }; export const juniorSqlSchema = { - juniorChannelConfigurations, juniorConversationAnnotations, juniorApiTokens, juniorAgentBindings, @@ -47,6 +46,7 @@ export const juniorSqlSchema = { juniorConversationEvents, juniorConversationBindings, juniorConversations, + juniorDestinationConfigurations, juniorDestinations, juniorEventTasks, juniorIdentities, diff --git a/packages/junior/src/db/schema/channel-configurations.ts b/packages/junior/src/db/schema/channel-configurations.ts deleted file mode 100644 index cf28de108b..0000000000 --- a/packages/junior/src/db/schema/channel-configurations.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { jsonb, pgTable, text } from "drizzle-orm/pg-core"; -import type { ChannelConfigState } from "@/chat/configuration/types"; -import { timestamptz } from "./timestamps"; - -/** Durable user configuration scoped to one provider channel. */ -export const juniorChannelConfigurations = pgTable( - "junior_channel_configurations", - { - channelId: text("channel_id").primaryKey(), - configuration: jsonb("configuration_json") - .$type() - .notNull(), - updatedAt: timestamptz("updated_at").notNull(), - }, -); diff --git a/packages/junior/src/db/schema/destination-configurations.ts b/packages/junior/src/db/schema/destination-configurations.ts new file mode 100644 index 0000000000..b453860376 --- /dev/null +++ b/packages/junior/src/db/schema/destination-configurations.ts @@ -0,0 +1,15 @@ +import { jsonb, pgTable, text } from "drizzle-orm/pg-core"; +import type { DestinationConfigState } from "@/chat/configuration/types"; +import { timestamptz } from "./timestamps"; + +/** Durable user configuration scoped to one destination/location. */ +export const juniorDestinationConfigurations = pgTable( + "junior_destination_configurations", + { + destinationKey: text("destination_key").primaryKey(), + configuration: jsonb("configuration_json") + .$type() + .notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, +); diff --git a/packages/junior/src/handlers/mcp-oauth-callback.ts b/packages/junior/src/handlers/mcp-oauth-callback.ts index 567d2a147b..0fe8c72c12 100644 --- a/packages/junior/src/handlers/mcp-oauth-callback.ts +++ b/packages/junior/src/handlers/mcp-oauth-callback.ts @@ -22,7 +22,7 @@ import { logException, logWarn } from "@/chat/logging"; import type { AgentRunResult } from "@/chat/services/turn-result"; import type { AgentRunner } from "@/chat/runtime/agent-runner"; import { - getChannelConfigurationServiceById, + getDestinationConfigurationService, getPersistedSandboxState, getPersistedThreadState, persistThreadStateById, @@ -343,9 +343,8 @@ async function resumeAuthorizedMcpTurn(args: { excludeMessageId: lockedUserMessage.id, }, ); - const lockedChannelConfiguration = getChannelConfigurationServiceById( - authSession.channelId!, - ); + const lockedDestinationConfiguration = + getDestinationConfigurationService(destination); let actor: Actor; try { actor = createSlackResumeActor({ @@ -415,7 +414,7 @@ async function resumeAuthorizedMcpTurn(args: { }, policy: { configuration: authSession.configuration, - channelConfiguration: lockedChannelConfiguration, + destinationConfiguration: lockedDestinationConfiguration, }, state: { artifactState: lockedArtifacts, diff --git a/packages/junior/src/handlers/oauth-callback.ts b/packages/junior/src/handlers/oauth-callback.ts index 552b37cb1b..28d875cc70 100644 --- a/packages/junior/src/handlers/oauth-callback.ts +++ b/packages/junior/src/handlers/oauth-callback.ts @@ -19,7 +19,7 @@ import { persistAuthPauseTurnState } from "@/chat/runtime/auth-pause-state"; import { logException, logInfo, logWarn, withLogContext } from "@/chat/logging"; import { htmlCallbackResponse } from "@/handlers/oauth-html"; import { - getChannelConfigurationServiceById, + getDestinationConfigurationService, getPersistedSandboxState, getPersistedThreadState, persistThreadStateById, @@ -358,9 +358,8 @@ async function resumeOAuthSessionRecordTurn( excludeMessageId: lockedUserMessage.id, }, ); - const lockedChannelConfiguration = getChannelConfigurationServiceById( - stored.channelId!, - ); + const lockedDestinationConfiguration = + getDestinationConfigurationService(destination); let actor: Actor; try { actor = createSlackResumeActor({ @@ -432,7 +431,7 @@ async function resumeOAuthSessionRecordTurn( lockedArtifacts.assistantContextChannelId ?? stored.channelId!, }, policy: { - channelConfiguration: lockedChannelConfiguration, + destinationConfiguration: lockedDestinationConfiguration, }, state: { artifactState: lockedArtifacts, diff --git a/packages/junior/tests/component/config/channel-configuration-sql.test.ts b/packages/junior/tests/component/config/destination-configuration-sql.test.ts similarity index 65% rename from packages/junior/tests/component/config/channel-configuration-sql.test.ts rename to packages/junior/tests/component/config/destination-configuration-sql.test.ts index 084b0bbf51..48010d3ddf 100644 --- a/packages/junior/tests/component/config/channel-configuration-sql.test.ts +++ b/packages/junior/tests/component/config/destination-configuration-sql.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "vitest"; -import { createDurableChannelConfigurationService } from "@/chat/configuration/sql"; +import { createDurableDestinationConfigurationService } from "@/chat/configuration/sql"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; import { createLocalJuniorSqlFixture } from "../../fixtures/sql"; +const DESTINATION = { + platform: "slack" as const, + teamId: "T-durable", + channelId: "C-durable", +}; + function legacyConfiguration(value: string) { return { configuration: { @@ -19,14 +25,14 @@ function legacyConfiguration(value: string) { }; } -describe("SQL channel configuration", () => { +describe("SQL destination configuration", () => { it("persists configuration independently of the legacy cache", async () => { const fixture = await createLocalJuniorSqlFixture(); await migrateSchema(fixture.sql); try { - const service = createDurableChannelConfigurationService({ - channelId: "C-durable", + const service = createDurableDestinationConfigurationService({ + destination: DESTINATION, db: fixture.sql.db(), loadLegacy: async () => null, }); @@ -36,8 +42,8 @@ describe("SQL channel configuration", () => { updatedBy: "U123", }); - const reloaded = createDurableChannelConfigurationService({ - channelId: "C-durable", + const reloaded = createDurableDestinationConfigurationService({ + destination: DESTINATION, db: fixture.sql.db(), loadLegacy: async () => { throw new Error("legacy configuration should not be read"); @@ -46,6 +52,9 @@ describe("SQL channel configuration", () => { await expect(reloaded.resolve("github.repo")).resolves.toBe( "getsentry/junior", ); + await expect(reloaded.get("github.repo")).resolves.toMatchObject({ + scope: "destination", + }); } finally { await fixture.close(); } @@ -56,8 +65,12 @@ describe("SQL channel configuration", () => { await migrateSchema(fixture.sql); try { - const service = createDurableChannelConfigurationService({ - channelId: "C-cutover", + const service = createDurableDestinationConfigurationService({ + destination: { + platform: "slack", + teamId: "T-cutover", + channelId: "C-cutover", + }, db: fixture.sql.db(), loadLegacy: async () => legacyConfiguration("getsentry/legacy"), }); @@ -65,8 +78,12 @@ describe("SQL channel configuration", () => { "getsentry/legacy", ); - const reloaded = createDurableChannelConfigurationService({ - channelId: "C-cutover", + const reloaded = createDurableDestinationConfigurationService({ + destination: { + platform: "slack", + teamId: "T-cutover", + channelId: "C-cutover", + }, db: fixture.sql.db(), loadLegacy: async () => null, }); diff --git a/packages/junior/tests/integration/agent-continue-slack.test.ts b/packages/junior/tests/integration/agent-continue-slack.test.ts index b03b0d724e..36dac82007 100644 --- a/packages/junior/tests/integration/agent-continue-slack.test.ts +++ b/packages/junior/tests/integration/agent-continue-slack.test.ts @@ -243,11 +243,13 @@ describe("paused turn Slack integration", () => { }, }, }); - await threadStateModule.getChannelConfigurationServiceById("C123").set({ - key: "demo.org", - value: "acme", - source: "test", - }); + await threadStateModule + .getDestinationConfigurationService(SLACK_DESTINATION) + .set({ + key: "demo.org", + value: "acme", + source: "test", + }); const continued = await continueAgentRun({ conversationId, @@ -281,7 +283,7 @@ describe("paused turn Slack integration", () => { ); const resumeContext = executeAgentRunMock.mock.calls[0]?.[0] as { policy?: { - channelConfiguration?: { + destinationConfiguration?: { resolve: (key: string) => Promise; }; turnDeadlineAtMs?: number; @@ -290,7 +292,7 @@ describe("paused turn Slack integration", () => { expect(resumeContext.policy?.turnDeadlineAtMs).toEqual(expect.any(Number)); expect(resumeContext.policy?.turnDeadlineAtMs).toBeGreaterThan(Date.now()); expect( - await resumeContext.policy?.channelConfiguration?.resolve("demo.org"), + await resumeContext.policy?.destinationConfiguration?.resolve("demo.org"), ).toBe("acme"); expect(slackApiOutbox.calls("assistant.threads.setStatus")).toEqual( diff --git a/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts b/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts index 4f58d81658..5e3989a8c0 100644 --- a/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts +++ b/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts @@ -6,7 +6,7 @@ import { createTestDestination, } from "../../fixtures/slack-harness"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; -import { getChannelConfigurationServiceById } from "@/chat/runtime/thread-state"; +import { getDestinationConfigurationService } from "@/chat/runtime/thread-state"; import { deliverAssistantMessagesForTest } from "../../fixtures/agent-runner"; function toPostedText(value: unknown): string { @@ -51,7 +51,9 @@ describe("Slack behavior: provider default configuration", () => { expect(thread.posts).toHaveLength(1); expect(toPostedText(thread.posts[0])).toContain("getsentry/junior"); await expect( - getChannelConfigurationServiceById(thread.channelId).get("github.repo"), + getDestinationConfigurationService(createTestDestination(thread)).get( + "github.repo", + ), ).resolves.toMatchObject({ key: "github.repo", value: "getsentry/junior", @@ -102,7 +104,9 @@ describe("Slack behavior: provider default configuration", () => { expect(executeAgentRun).toHaveBeenCalledOnce(); expect(toPostedText(thread.posts[0])).toContain("Created the issue."); await expect( - getChannelConfigurationServiceById(thread.channelId).get("github.repo"), + getDestinationConfigurationService(createTestDestination(thread)).get( + "github.repo", + ), ).resolves.toBeUndefined(); }); }); diff --git a/packages/junior/tests/unit/config/configuration-service.test.ts b/packages/junior/tests/unit/config/configuration-service.test.ts index 1737e5da52..f6fe84f962 100644 --- a/packages/junior/tests/unit/config/configuration-service.test.ts +++ b/packages/junior/tests/unit/config/configuration-service.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createChannelConfigurationService } from "@/chat/configuration/service"; +import { createDestinationConfigurationService } from "@/chat/configuration/service"; function createInMemoryService() { let state: Record | null = null; - const service = createChannelConfigurationService({ + const service = createDestinationConfigurationService({ load: async () => state, save: async (next) => { state = { @@ -18,7 +18,7 @@ function createInMemoryService() { }; } -describe("channel configuration service", () => { +describe("destination configuration service", () => { it("sets, gets, lists, resolves, and unsets entries", async () => { const { service, getState } = createInMemoryService(); @@ -29,7 +29,7 @@ describe("channel configuration service", () => { source: "test", }); expect(created.key).toBe("github.repo"); - expect(created.scope).toBe("conversation"); + expect(created.scope).toBe("destination"); expect(created.updatedBy).toBe("U123"); expect(created.source).toBe("test"); @@ -72,7 +72,7 @@ describe("channel configuration service", () => { "jira.project": expect.objectContaining({ key: "jira.project", value: "PLAT", - scope: "conversation", + scope: "destination", }), }, }, diff --git a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts index c819b57c4b..5eb1da38c6 100644 --- a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts +++ b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { maybeExecuteJrRpcCustomCommand } from "@/chat/capabilities/jr-rpc-command"; -import { createChannelConfigurationService } from "@/chat/configuration/service"; +import { createDestinationConfigurationService } from "@/chat/configuration/service"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; import type { Skill } from "@/chat/skills"; @@ -12,9 +12,9 @@ const activeSkill: Skill = { pluginProvider: "github", }; -function makeChannelConfiguration() { +function makeDestinationConfiguration() { let state: Record | null = null; - return createChannelConfigurationService({ + return createDestinationConfigurationService({ load: async () => state, save: async (next) => { state = { @@ -64,14 +64,14 @@ describe("jr-rpc custom command", () => { }); it("sets and gets configuration values", async () => { - const configuration = makeChannelConfiguration(); + const configuration = makeDestinationConfiguration(); const onConfigurationValueChanged = vi.fn(); const setResult = await maybeExecuteJrRpcCustomCommand( "jr-rpc config set github.repo getsentry/junior", { activeSkill, - channelConfiguration: configuration, + destinationConfiguration: configuration, actorId: "U123", onConfigurationValueChanged, }, @@ -86,7 +86,7 @@ describe("jr-rpc custom command", () => { "jr-rpc config get github.repo", { activeSkill, - channelConfiguration: configuration, + destinationConfiguration: configuration, actorId: "U123", }, ); @@ -100,7 +100,7 @@ describe("jr-rpc custom command", () => { }); it("supports config list with a prefix filter", async () => { - const configuration = makeChannelConfiguration(); + const configuration = makeDestinationConfiguration(); await configuration.set({ key: "github.repo", value: "getsentry/junior", @@ -118,7 +118,7 @@ describe("jr-rpc custom command", () => { "jr-rpc config list --prefix github.", { activeSkill, - channelConfiguration: configuration, + destinationConfiguration: configuration, actorId: "U123", }, ); @@ -175,7 +175,7 @@ describe("jr-rpc custom command", () => { }); it("unsets configuration values", async () => { - const configuration = makeChannelConfiguration(); + const configuration = makeDestinationConfiguration(); const onConfigurationValueChanged = vi.fn(); await configuration.set({ @@ -189,7 +189,7 @@ describe("jr-rpc custom command", () => { "jr-rpc config unset github.repo", { activeSkill, - channelConfiguration: configuration, + destinationConfiguration: configuration, actorId: "U123", onConfigurationValueChanged, }, @@ -225,12 +225,12 @@ describe("jr-rpc custom command", () => { ], }); try { - const configuration = makeChannelConfiguration(); + const configuration = makeDestinationConfiguration(); const result = await maybeExecuteJrRpcCustomCommand( "jr-rpc config set cloudflare.worker-id sentry-mcp", { activeSkill, - channelConfiguration: configuration, + destinationConfiguration: configuration, actorId: "U123", }, ); @@ -265,12 +265,12 @@ describe("jr-rpc custom command", () => { ], }); try { - const configuration = makeChannelConfiguration(); + const configuration = makeDestinationConfiguration(); const result = await maybeExecuteJrRpcCustomCommand( "jr-rpc config set cloudflare.worker.name sentry-mcp", { activeSkill, - channelConfiguration: configuration, + destinationConfiguration: configuration, actorId: "U123", }, ); From 74222f1a58783f420267c2b1c4f5f1f25d6e6552 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:31:50 +0000 Subject: [PATCH 4/9] ref(chat): Scope durable configuration to locations Key configuration by the canonical Location row instead of a serialized Destination. Resolve or create the provider Location before reading or writing configuration. Co-Authored-By: David Cramer --- .../0024_destination_configuration.sql | 5 - .../0024_location_configuration.sql | 7 + .../junior/migrations/meta/0024_snapshot.json | 78 +++++++----- packages/junior/migrations/meta/_journal.json | 6 +- .../junior/src/chat/agent-dispatch/types.ts | 4 +- packages/junior/src/chat/agent/index.ts | 4 +- packages/junior/src/chat/agent/request.ts | 4 +- packages/junior/src/chat/agent/sandbox.ts | 6 +- packages/junior/src/chat/agent/tools.ts | 2 +- packages/junior/src/chat/app/factory.ts | 4 +- .../src/chat/capabilities/jr-rpc-command.ts | 14 +- .../junior/src/chat/configuration/service.ts | 36 +++--- packages/junior/src/chat/configuration/sql.ts | 120 ++++++++++++++---- .../junior/src/chat/configuration/types.ts | 10 +- .../junior/src/chat/runtime/reply-executor.ts | 6 +- .../junior/src/chat/runtime/slack-resume.ts | 12 +- .../junior/src/chat/runtime/thread-state.ts | 12 +- .../junior/src/chat/runtime/turn-input.ts | 4 +- .../src/chat/runtime/turn-preparation.ts | 16 +-- .../chat/services/provider-default-config.ts | 8 +- .../junior/src/chat/slack/dispatch-turn.ts | 6 +- .../src/chat/task-execution/paused-turn.ts | 8 +- packages/junior/src/db/schema.ts | 6 +- .../db/schema/destination-configurations.ts | 15 --- .../src/db/schema/location-configurations.ts | 18 +++ .../junior/src/handlers/mcp-oauth-callback.ts | 8 +- .../junior/src/handlers/oauth-callback.ts | 8 +- ....ts => location-configuration-sql.test.ts} | 14 +- .../integration/agent-continue-slack.test.ts | 6 +- .../provider-default-config-behavior.test.ts | 6 +- .../unit/config/configuration-service.test.ts | 10 +- .../unit/handlers/jr-rpc-command.test.ts | 28 ++-- 32 files changed, 289 insertions(+), 202 deletions(-) delete mode 100644 packages/junior/migrations/0024_destination_configuration.sql create mode 100644 packages/junior/migrations/0024_location_configuration.sql delete mode 100644 packages/junior/src/db/schema/destination-configurations.ts create mode 100644 packages/junior/src/db/schema/location-configurations.ts rename packages/junior/tests/component/config/{destination-configuration-sql.test.ts => location-configuration-sql.test.ts} (83%) diff --git a/packages/junior/migrations/0024_destination_configuration.sql b/packages/junior/migrations/0024_destination_configuration.sql deleted file mode 100644 index 74124011bc..0000000000 --- a/packages/junior/migrations/0024_destination_configuration.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE "junior_destination_configurations" ( - "destination_key" text PRIMARY KEY NOT NULL, - "configuration_json" jsonb NOT NULL, - "updated_at" timestamp with time zone NOT NULL -); diff --git a/packages/junior/migrations/0024_location_configuration.sql b/packages/junior/migrations/0024_location_configuration.sql new file mode 100644 index 0000000000..1b271b59ba --- /dev/null +++ b/packages/junior/migrations/0024_location_configuration.sql @@ -0,0 +1,7 @@ +CREATE TABLE "junior_location_configurations" ( + "location_id" text PRIMARY KEY NOT NULL, + "configuration_json" jsonb NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +ALTER TABLE "junior_location_configurations" ADD CONSTRAINT "junior_location_configurations_location_id_junior_destinations_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."junior_destinations"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0024_snapshot.json b/packages/junior/migrations/meta/0024_snapshot.json index b17096d04d..4f09346bda 100644 --- a/packages/junior/migrations/meta/0024_snapshot.json +++ b/packages/junior/migrations/meta/0024_snapshot.json @@ -1,5 +1,5 @@ { - "id": "12898d5c-be75-48cf-a404-b8ce24dc69a7", + "id": "b4046f37-2d26-431c-9cb8-ff5f4babce23", "prevId": "86dbef33-04cf-4e3d-b324-b321a3af4af8", "version": "7", "dialect": "postgresql", @@ -1144,37 +1144,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.junior_destination_configurations": { - "name": "junior_destination_configurations", - "schema": "", - "columns": { - "destination_key": { - "name": "destination_key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "configuration_json": { - "name": "configuration_json", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.junior_destinations": { "name": "junior_destinations", "schema": "", @@ -1620,6 +1589,51 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "configuration_json": { + "name": "configuration_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.junior_scheduler_runs": { "name": "junior_scheduler_runs", "schema": "", diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 76a97abc1a..9089e5036a 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -173,9 +173,9 @@ { "idx": 24, "version": "7", - "when": 1786310479611, - "tag": "0024_destination_configuration", + "when": 1786310988956, + "tag": "0024_location_configuration", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/junior/src/chat/agent-dispatch/types.ts b/packages/junior/src/chat/agent-dispatch/types.ts index 08d51c4017..6bb9cf798b 100644 --- a/packages/junior/src/chat/agent-dispatch/types.ts +++ b/packages/junior/src/chat/agent-dispatch/types.ts @@ -12,7 +12,7 @@ import type { } from "@/chat/credentials/context"; import type { AgentRunRouting } from "@/chat/agent/request"; import type { AgentTurnSurface } from "@/chat/task-execution/checkpoint"; -import type { DestinationConfigurationService } from "@/chat/configuration/types"; +import type { LocationConfigurationService } from "@/chat/configuration/types"; export type DispatchStatus = | "pending" @@ -80,7 +80,7 @@ export interface DispatchTurnResult { /** Dispatch-owned authority supplied to the shared turn runtime. */ export interface DispatchTurnContext { disabledFeatures: readonly ["interactive-auth"]; - destinationConfiguration: DestinationConfigurationService; + locationConfiguration: LocationConfigurationService; credentialContext: CredentialContext; destinationVisibility: DestinationVisibility; dispatch: NonNullable; diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index a4c43e698e..234da8d57a 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -449,8 +449,8 @@ async function executeAgentRunInPrivacyContext( }); } }; - const persistedConfigurationValues = policy.destinationConfiguration - ? await policy.destinationConfiguration.resolveValues() + const persistedConfigurationValues = policy.locationConfiguration + ? await policy.locationConfiguration.resolveValues() : {}; const configurationValues: Record = { ...getConfigDefaults(), diff --git a/packages/junior/src/chat/agent/request.ts b/packages/junior/src/chat/agent/request.ts index 7b5ede27c5..4d15153015 100644 --- a/packages/junior/src/chat/agent/request.ts +++ b/packages/junior/src/chat/agent/request.ts @@ -13,7 +13,7 @@ import type { Source, SystemActor, } from "@sentry/junior-plugin-api"; -import type { DestinationConfigurationService } from "@/chat/configuration/types"; +import type { LocationConfigurationService } from "@/chat/configuration/types"; import type { ConversationPrivacy } from "@/chat/conversation-privacy"; import type { CredentialContext } from "@/chat/credentials/context"; import type { PiMessage } from "@/chat/pi/messages"; @@ -164,7 +164,7 @@ export interface AgentRunPolicy { /** Explicit per-agent reasoning level. When set, adaptive routing is disabled. */ reasoningLevel?: TurnReasoningLevel; configuration?: Record; - destinationConfiguration?: DestinationConfigurationService; + locationConfiguration?: LocationConfigurationService; skillDirs?: string[]; /** Per-slice override for app-owned sandbox egress trace propagation. */ sandboxTracePropagation?: SandboxEgressTracePropagationConfig; diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index f034aeb6fb..18bd6a5428 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -9,7 +9,7 @@ import type { FileUpload } from "chat"; import type { PluginSandbox } from "@sentry/junior-plugin-api"; import { isUserActor, type Actor } from "@/chat/actor"; import { maybeExecuteJrRpcCustomCommand } from "@/chat/capabilities/jr-rpc-command"; -import type { DestinationConfigurationService } from "@/chat/configuration/types"; +import type { LocationConfigurationService } from "@/chat/configuration/types"; import type { CredentialContext } from "@/chat/credentials/context"; import { listReferenceFiles } from "@/chat/discovery"; import type { LogContext } from "@/chat/logging"; @@ -35,7 +35,7 @@ export interface AgentSandboxOptions { egressSignals?: SandboxEgressSignalTransport; credentialEgress?: CredentialContext; actor?: Actor; - destinationConfiguration?: DestinationConfigurationService; + locationConfiguration?: LocationConfigurationService; configurationValues: Record; getActiveSkill(): Skill | null; prepareSandbox(workspace: SandboxWorkspace): void | Promise; @@ -165,7 +165,7 @@ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { if (command) { const result = await maybeExecuteJrRpcCustomCommand(command, { activeSkill: options.getActiveSkill(), - destinationConfiguration: options.destinationConfiguration, + locationConfiguration: options.locationConfiguration, actorId: isUserActor(options.actor) ? options.actor.userId : undefined, diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index c9afd18ae6..8b24e34e8d 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -182,7 +182,7 @@ export async function wireAgentTools( egressSignals: args.policy.sandboxEgressSignals, credentialEgress: args.routing.credentialContext, actor: args.currentActor, - destinationConfiguration: args.policy.destinationConfiguration, + locationConfiguration: args.policy.locationConfiguration, configurationValues: args.configurationValues, getActiveSkill: () => args.skillSandbox.getActiveSkill(), prepareSandbox: pluginHooks.prepareSandbox, diff --git a/packages/junior/src/chat/app/factory.ts b/packages/junior/src/chat/app/factory.ts index 1e348cf7ba..60030685d0 100644 --- a/packages/junior/src/chat/app/factory.ts +++ b/packages/junior/src/chat/app/factory.ts @@ -21,7 +21,7 @@ import { stripLeadingBotMention, } from "@/chat/runtime/thread-context"; import { - getDestinationConfigurationService, + getLocationConfigurationService, getPersistedThreadState, mergeArtifactsState, persistThreadState, @@ -241,7 +241,7 @@ export function createSlackRuntime(options: CreateSlackRuntimeOptions) { return { ...runtime, runDispatchTurn: createSlackDispatchTurnRunner({ - getDestinationConfiguration: getDestinationConfigurationService, + getLocationConfiguration: getLocationConfigurationService, getSlackAdapter: options.getSlackAdapter, replyToThread, }), diff --git a/packages/junior/src/chat/capabilities/jr-rpc-command.ts b/packages/junior/src/chat/capabilities/jr-rpc-command.ts index f021ca5421..eefd58d355 100644 --- a/packages/junior/src/chat/capabilities/jr-rpc-command.ts +++ b/packages/junior/src/chat/capabilities/jr-rpc-command.ts @@ -1,12 +1,12 @@ import { Bash, defineCommand } from "just-bash"; -import type { DestinationConfigurationService } from "@/chat/configuration/types"; +import type { LocationConfigurationService } from "@/chat/configuration/types"; import { logInfo } from "@/chat/logging"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; import type { Skill } from "@/chat/skills"; type JrRpcDeps = { activeSkill: Skill | null; - destinationConfiguration?: DestinationConfigurationService; + locationConfiguration?: LocationConfigurationService; actorId?: string; onConfigurationValueChanged?: ( key: string, @@ -32,13 +32,13 @@ function commandResult(input: { }; } -function requireDestinationConfiguration( +function requireLocationConfiguration( deps: JrRpcDeps, ): - | { ok: true; configuration: DestinationConfigurationService } + | { ok: true; configuration: LocationConfigurationService } | { ok: false; result: ReturnType } { - if (deps.destinationConfiguration) { - return { ok: true, configuration: deps.destinationConfiguration }; + if (deps.locationConfiguration) { + return { ok: true, configuration: deps.locationConfiguration }; } return { ok: false, @@ -100,7 +100,7 @@ async function handleConfigCommand( "jr-rpc config list [--prefix ]", ].join("\n"); const subverb = (args[0] ?? "").trim(); - const configurationResult = requireDestinationConfiguration(deps); + const configurationResult = requireLocationConfiguration(deps); if (!configurationResult.ok) { return configurationResult.result; } diff --git a/packages/junior/src/chat/configuration/service.ts b/packages/junior/src/chat/configuration/service.ts index 0e3e84ef59..8891d6b999 100644 --- a/packages/junior/src/chat/configuration/service.ts +++ b/packages/junior/src/chat/configuration/service.ts @@ -1,7 +1,7 @@ import type { - DestinationConfigState, - DestinationConfigurationService, - DestinationConfigurationStorage, + LocationConfigState, + LocationConfigurationService, + LocationConfigurationStorage, ConfigEntry, } from "@/chat/configuration/types"; import { @@ -10,7 +10,7 @@ import { } from "@/chat/configuration/validation"; import { isRecord, toOptionalString } from "@/chat/coerce"; -function defaultState(): DestinationConfigState { +function defaultState(): LocationConfigState { return { schemaVersion: 1, entries: {}, @@ -33,15 +33,15 @@ function sanitizeEntry(value: unknown): ConfigEntry | undefined { if (!updatedAt) { return undefined; } - // Accept legacy "channel" / "conversation" scopes from Redis and early SQL rows. + // Accept legacy "channel" / "conversation" scopes from Redis. if ( value.scope !== "channel" && value.scope !== "conversation" && - value.scope !== "destination" + value.scope !== "location" ) { return undefined; } - const scope = "destination" as const; + const scope = "location" as const; return { key, @@ -54,10 +54,10 @@ function sanitizeEntry(value: unknown): ConfigEntry | undefined { }; } -/** Coerce persisted destination configuration into the current durable shape. */ -export function coerceDestinationConfigState( +/** Coerce persisted location configuration into the current durable shape. */ +export function coerceLocationConfigState( raw: unknown, -): DestinationConfigState { +): LocationConfigState { if (!isRecord(raw)) { return defaultState(); } @@ -79,15 +79,15 @@ export function coerceDestinationConfigState( }; } -export function createDestinationConfigurationService( - storage: DestinationConfigurationStorage, -): DestinationConfigurationService { - const getState = async (): Promise => { +export function createLocationConfigurationService( + storage: LocationConfigurationStorage, +): LocationConfigurationService { + const getState = async (): Promise => { const loaded = await storage.load(); - return coerceDestinationConfigState(loaded); + return coerceLocationConfigState(loaded); }; - const saveState = async (state: DestinationConfigState): Promise => { + const saveState = async (state: LocationConfigState): Promise => { await storage.save({ schemaVersion: 1, entries: state.entries, @@ -100,7 +100,7 @@ export function createDestinationConfigurationService( return state.entries[normalizedKey]; }; - const set: DestinationConfigurationService["set"] = async (input) => { + const set: LocationConfigurationService["set"] = async (input) => { const normalizedKey = input.key.trim(); const keyError = validateConfigKey(normalizedKey); if (keyError) { @@ -116,7 +116,7 @@ export function createDestinationConfigurationService( const nextEntry: ConfigEntry = { key: normalizedKey, value: input.value, - scope: "destination", + scope: "location", updatedAt: new Date().toISOString(), updatedBy: toOptionalString(input.updatedBy), source: toOptionalString(input.source), diff --git a/packages/junior/src/chat/configuration/sql.ts b/packages/junior/src/chat/configuration/sql.ts index 9fd4eb7333..4a8f1d929c 100644 --- a/packages/junior/src/chat/configuration/sql.ts +++ b/packages/junior/src/chat/configuration/sql.ts @@ -1,62 +1,130 @@ +import { randomUUID } from "node:crypto"; import type { Destination } from "@sentry/junior-plugin-api"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { JuniorDatabase } from "@/db/db"; -import { juniorDestinationConfigurations } from "@/db/schema"; -import { destinationKey } from "@/chat/destination"; +import { juniorDestinations, juniorLocationConfigurations } from "@/db/schema"; import { - coerceDestinationConfigState, - createDestinationConfigurationService, + coerceLocationConfigState, + createLocationConfigurationService, } from "@/chat/configuration/service"; import type { - DestinationConfigState, - DestinationConfigurationService, - DestinationConfigurationStorage, + LocationConfigState, + LocationConfigurationService, + LocationConfigurationStorage, } from "@/chat/configuration/types"; -/** Create durable destination configuration storage for one destination key. */ -function createSqlDestinationConfigurationStorage( +/** Resolve the canonical Location row for a supported provider destination. */ +async function resolveLocationId( db: JuniorDatabase, - key: string, -): DestinationConfigurationStorage { + destination: Destination, +): Promise { + if (destination.platform !== "slack") { + throw new Error("Location configuration requires a provider Location"); + } + + const channelId = destination.channelId; + const now = new Date(); + const rows = await db + .insert(juniorDestinations) + .values({ + id: randomUUID(), + provider: "slack", + providerTenantId: destination.teamId, + providerDestinationId: channelId, + kind: channelId.startsWith("D") + ? "dm" + : channelId.startsWith("G") + ? "group" + : "channel", + parentDestinationId: null, + displayName: null, + visibility: "unknown", + metadata: { platform: "slack" }, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ + target: [ + juniorDestinations.provider, + juniorDestinations.providerTenantId, + juniorDestinations.providerDestinationId, + ], + }) + .returning({ id: juniorDestinations.id }); + const locationId = rows[0]?.id; + if (locationId) { + return locationId; + } + + const existing = await db + .select({ id: juniorDestinations.id }) + .from(juniorDestinations) + .where( + and( + eq(juniorDestinations.provider, "slack"), + eq(juniorDestinations.providerTenantId, destination.teamId), + eq(juniorDestinations.providerDestinationId, channelId), + ), + ) + .limit(1); + if (!existing[0]?.id) { + throw new Error("Location could not be resolved"); + } + return existing[0].id; +} + +/** Create durable configuration storage for one Location. */ +function createSqlLocationConfigurationStorage( + db: JuniorDatabase, + destination: Destination, +): LocationConfigurationStorage { + let locationIdPromise: Promise | undefined; + const getLocationId = () => + (locationIdPromise ??= resolveLocationId(db, destination)); + return { load: async () => { + const locationId = await getLocationId(); const rows = await db - .select({ configuration: juniorDestinationConfigurations.configuration }) - .from(juniorDestinationConfigurations) - .where(eq(juniorDestinationConfigurations.destinationKey, key)) + .select({ configuration: juniorLocationConfigurations.configuration }) + .from(juniorLocationConfigurations) + .where(eq(juniorLocationConfigurations.locationId, locationId)) .limit(1); const configuration = rows[0]?.configuration; return configuration ? { configuration } : null; }, - save: async (configuration: DestinationConfigState) => { + save: async (configuration: LocationConfigState) => { + const locationId = await getLocationId(); const updatedAt = new Date(); await db - .insert(juniorDestinationConfigurations) - .values({ destinationKey: key, configuration, updatedAt }) + .insert(juniorLocationConfigurations) + .values({ locationId, configuration, updatedAt }) .onConflictDoUpdate({ - target: juniorDestinationConfigurations.destinationKey, + target: juniorLocationConfigurations.locationId, set: { configuration, updatedAt }, }); }, }; } -/** Resolve SQL-owned destination configuration and copy a live legacy record once. */ -export function createDurableDestinationConfigurationService(args: { +/** Resolve SQL-owned Location configuration and copy a live legacy record once. */ +export function createDurableLocationConfigurationService(args: { destination: Destination; db: JuniorDatabase; loadLegacy: () => Promise; -}): DestinationConfigurationService { - const key = destinationKey(args.destination); - const sqlStorage = createSqlDestinationConfigurationStorage(args.db, key); - return createDestinationConfigurationService({ +}): LocationConfigurationService { + const sqlStorage = createSqlLocationConfigurationStorage( + args.db, + args.destination, + ); + return createLocationConfigurationService({ load: async () => { const durable = await sqlStorage.load(); if (durable) { return durable; } // TODO(#1267, v0.147.0): Remove after SQL readers have copied all live 7-day Redis records. - const legacyState = coerceDestinationConfigState(await args.loadLegacy()); + const legacyState = coerceLocationConfigState(await args.loadLegacy()); if (Object.keys(legacyState.entries).length === 0) { return null; } diff --git a/packages/junior/src/chat/configuration/types.ts b/packages/junior/src/chat/configuration/types.ts index f10d97ee0f..25844217d4 100644 --- a/packages/junior/src/chat/configuration/types.ts +++ b/packages/junior/src/chat/configuration/types.ts @@ -1,4 +1,4 @@ -export type ConfigScope = "destination"; +export type ConfigScope = "location"; export interface ConfigEntry { key: string; @@ -10,17 +10,17 @@ export interface ConfigEntry { expiresAt?: string; } -export interface DestinationConfigState { +export interface LocationConfigState { schemaVersion: 1; entries: Record; } -export interface DestinationConfigurationStorage { +export interface LocationConfigurationStorage { load: () => Promise; - save: (state: DestinationConfigState) => Promise; + save: (state: LocationConfigState) => Promise; } -export interface DestinationConfigurationService { +export interface LocationConfigurationService { get: (key: string) => Promise; set: (input: { key: string; diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 70541f6a23..64722b2128 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -581,7 +581,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { ), queuedMessages: options.queuedMessages, destination, - destinationConfiguration: options.execution?.destinationConfiguration, + locationConfiguration: options.execution?.locationConfiguration, context: { threadId, actorId: slackActorId, @@ -872,7 +872,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { const configReply = options.execution?.skipProviderDefaultConfig ? undefined : await maybeApplyProviderDefaultConfigRequest({ - destinationConfiguration: preparedState.destinationConfiguration, + locationConfiguration: preparedState.locationConfiguration, actorId: actor?.userId, text: effectiveUserText, }); @@ -1388,7 +1388,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { }, policy: { configuration: preparedState.configuration, - destinationConfiguration: preparedState.destinationConfiguration, + locationConfiguration: preparedState.locationConfiguration, disabledFeatures: options.execution?.disabledFeatures ?? (message.author.isBot === true diff --git a/packages/junior/src/chat/runtime/slack-resume.ts b/packages/junior/src/chat/runtime/slack-resume.ts index a728b26dfc..10c853b8d1 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -8,7 +8,7 @@ import type { ReplyAttribution } from "@sentry/junior-plugin-api"; import { botConfig } from "@/chat/config"; import { standardModelId } from "@/chat/model-profile"; -import type { DestinationConfigurationService } from "@/chat/configuration/types"; +import type { LocationConfigurationService } from "@/chat/configuration/types"; import { RetryableDeliveryError, type AgentRunRequest, @@ -118,11 +118,11 @@ async function postSlackMessageBestEffort( /** Create a read-only configuration service from persisted values. */ function createReadOnlyConfigService( values: Record, -): DestinationConfigurationService { +): LocationConfigurationService { const entries = Object.entries(values).map(([key, value]) => ({ key, value, - scope: "destination" as const, + scope: "location" as const, updatedAt: new Date().toISOString(), })); @@ -352,8 +352,8 @@ function createResumeReplyContext( const requestDeadline = getTurnRequestDeadline(); const threadId = args.lockKey ?? getDefaultLockKey(args.channelId, args.threadTs); - const persistedDestinationConfiguration = - replyContext.policy?.destinationConfiguration ?? + const persistedLocationConfiguration = + replyContext.policy?.locationConfiguration ?? (replyContext.policy?.configuration ? createReadOnlyConfigService(replyContext.policy.configuration) : undefined); @@ -380,7 +380,7 @@ function createResumeReplyContext( ...replyContext.policy, turnDeadlineAtMs: replyContext.policy?.turnDeadlineAtMs ?? requestDeadline?.deadlineAtMs, - destinationConfiguration: persistedDestinationConfiguration, + locationConfiguration: persistedLocationConfiguration, }, state: replyContext.state, observers: { diff --git a/packages/junior/src/chat/runtime/thread-state.ts b/packages/junior/src/chat/runtime/thread-state.ts index 4c5eba07b5..6b053102fb 100644 --- a/packages/junior/src/chat/runtime/thread-state.ts +++ b/packages/junior/src/chat/runtime/thread-state.ts @@ -1,8 +1,8 @@ import type { Thread } from "chat"; import type { Destination } from "@sentry/junior-plugin-api"; import { toOptionalString } from "@/chat/coerce"; -import { createDurableDestinationConfigurationService } from "@/chat/configuration/sql"; -import type { DestinationConfigurationService } from "@/chat/configuration/types"; +import { createDurableLocationConfigurationService } from "@/chat/configuration/sql"; +import type { LocationConfigurationService } from "@/chat/configuration/types"; import { getDb } from "@/chat/db"; import { buildConversationStatePatch } from "@/chat/state/conversation"; import type { ThreadConversationState } from "@/chat/state/conversation"; @@ -194,14 +194,14 @@ async function getLegacyChannelState( } /** - * Resolve durable destination configuration. + * Resolve durable location configuration. * * Slack still reads the old channel-id Redis bag once during cutover. */ -export function getDestinationConfigurationService( +export function getLocationConfigurationService( destination: Destination, -): DestinationConfigurationService { - return createDurableDestinationConfigurationService({ +): LocationConfigurationService { + return createDurableLocationConfigurationService({ destination, db: getDb(), loadLegacy: async () => { diff --git a/packages/junior/src/chat/runtime/turn-input.ts b/packages/junior/src/chat/runtime/turn-input.ts index 9aaf525908..47381cc83e 100644 --- a/packages/junior/src/chat/runtime/turn-input.ts +++ b/packages/junior/src/chat/runtime/turn-input.ts @@ -1,6 +1,6 @@ import type { Message, Thread } from "chat"; import type { Destination } from "@sentry/junior-plugin-api"; -import type { DestinationConfigurationService } from "@/chat/configuration/types"; +import type { LocationConfigurationService } from "@/chat/configuration/types"; export interface TurnContext { channelId?: string; @@ -26,7 +26,7 @@ export interface QueuedTurnMessage extends TurnMessageText { export interface PrepareTurnStateInput { destination: Destination; - destinationConfiguration?: DestinationConfigurationService; + locationConfiguration?: LocationConfigurationService; context: TurnContext; explicitMention: boolean; message: Message; diff --git a/packages/junior/src/chat/runtime/turn-preparation.ts b/packages/junior/src/chat/runtime/turn-preparation.ts index 123663e0d6..db1d791c10 100644 --- a/packages/junior/src/chat/runtime/turn-preparation.ts +++ b/packages/junior/src/chat/runtime/turn-preparation.ts @@ -31,7 +31,7 @@ import { isVisionEnabled, } from "@/chat/slack/vision-context"; import { - getDestinationConfigurationService, + getLocationConfigurationService, getPersistedSandboxState, } from "@/chat/runtime/thread-state"; import { @@ -39,7 +39,7 @@ import { persistConversationMessages, } from "@/chat/conversations/messages"; import { persistConversationMessageSummaries } from "@/chat/conversations/message-summaries"; -import type { DestinationConfigurationService } from "@/chat/configuration/types"; +import type { LocationConfigurationService } from "@/chat/configuration/types"; import { appendSlackLegacyAttachmentText } from "@/chat/slack/legacy-attachments"; import { getSlackMessageText } from "@/chat/slack/message"; import type { @@ -53,7 +53,7 @@ const BACKFILL_MESSAGE_LIMIT = 80; export interface PreparedTurnState { artifacts: ThreadArtifactsState; configuration?: Record; - destinationConfiguration?: DestinationConfigurationService; + locationConfiguration?: LocationConfigurationService; conversation: ThreadConversationState; conversationContext?: string; sandboxRef?: SandboxRef; @@ -173,10 +173,10 @@ export function createPrepareTurnState(deps: PrepareTurnStateDeps) { const conversation = coerceThreadConversationState(existingState); const conversationId = args.context.threadId ?? args.context.runId; await hydrateConversationMessages({ conversation, conversationId }); - const destinationConfiguration = - args.destinationConfiguration ?? - getDestinationConfigurationService(args.destination); - const configuration = await destinationConfiguration.resolveValues(); + const locationConfiguration = + args.locationConfiguration ?? + getLocationConfigurationService(args.destination); + const configuration = await locationConfiguration.resolveValues(); const backfillSource = args.skipBackfill ? undefined @@ -259,7 +259,7 @@ export function createPrepareTurnState(deps: PrepareTurnStateDeps) { return { artifacts, configuration, - destinationConfiguration, + locationConfiguration, conversation, sandboxRef, conversationContext, diff --git a/packages/junior/src/chat/services/provider-default-config.ts b/packages/junior/src/chat/services/provider-default-config.ts index afa2a5d39b..04605d6211 100644 --- a/packages/junior/src/chat/services/provider-default-config.ts +++ b/packages/junior/src/chat/services/provider-default-config.ts @@ -1,4 +1,4 @@ -import type { DestinationConfigurationService } from "@/chat/configuration/types"; +import type { LocationConfigurationService } from "@/chat/configuration/types"; const GITHUB_REPO_PART = String.raw`[A-Za-z0-9_.-]*[A-Za-z0-9_-]`; const GITHUB_REPO_RE = new RegExp( @@ -8,17 +8,17 @@ const GITHUB_REPO_RE = new RegExp( /** Apply explicit provider-default config requests that do not need agent reasoning. */ export async function maybeApplyProviderDefaultConfigRequest(args: { - destinationConfiguration?: DestinationConfigurationService; + locationConfiguration?: LocationConfigurationService; actorId?: string; text: string; }): Promise<{ text: string } | null> { const match = GITHUB_REPO_RE.exec(args.text); const repo = match?.[1]; - if (!repo || !args.destinationConfiguration) { + if (!repo || !args.locationConfiguration) { return null; } - await args.destinationConfiguration.set({ + await args.locationConfiguration.set({ key: "github.repo", value: repo, updatedBy: args.actorId, diff --git a/packages/junior/src/chat/slack/dispatch-turn.ts b/packages/junior/src/chat/slack/dispatch-turn.ts index a3d2823c21..80d15aab02 100644 --- a/packages/junior/src/chat/slack/dispatch-turn.ts +++ b/packages/junior/src/chat/slack/dispatch-turn.ts @@ -32,9 +32,9 @@ interface DispatchReplyToThread { /** Build the Slack provider adapter for agent-dispatched conversation turns. */ export function createSlackDispatchTurnRunner(options: { - getDestinationConfiguration: ( + getLocationConfiguration: ( destination: DispatchRecord["destination"], - ) => DispatchTurnContext["destinationConfiguration"]; + ) => DispatchTurnContext["locationConfiguration"]; getSlackAdapter: () => SlackAdapter; replyToThread: DispatchReplyToThread; }) { @@ -95,7 +95,7 @@ export function createSlackDispatchTurnRunner(options: { destination: dispatch.destination, execution: { disabledFeatures: ["interactive-auth"], - destinationConfiguration: options.getDestinationConfiguration( + locationConfiguration: options.getLocationConfiguration( dispatch.destination, ), credentialContext: routing.credentialContext, diff --git a/packages/junior/src/chat/task-execution/paused-turn.ts b/packages/junior/src/chat/task-execution/paused-turn.ts index 1f76b626d1..bd043ea53a 100644 --- a/packages/junior/src/chat/task-execution/paused-turn.ts +++ b/packages/junior/src/chat/task-execution/paused-turn.ts @@ -31,7 +31,7 @@ import { getPersistedThreadState, getPersistedSandboxState, persistThreadStateById, - getDestinationConfigurationService, + getLocationConfigurationService, } from "@/chat/runtime/thread-state"; import { buildDeliveredTurnStatePatch } from "@/chat/runtime/delivered-turn-state"; import { @@ -473,8 +473,8 @@ async function runPausedTurnInContext( } const { actor, credentialContext } = identity; - const destinationConfiguration = - getDestinationConfigurationService(destination); + const locationConfiguration = + getLocationConfigurationService(destination); const conversationContext = dispatchId ? undefined : buildConversationContext(conversation, { @@ -543,7 +543,7 @@ async function runPausedTurnInContext( artifacts.assistantContextChannelId ?? destination.channelId, }, policy: { - destinationConfiguration, + locationConfiguration, }, state: { artifactState: artifacts, diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index 8499660bbe..bb329c4822 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -7,7 +7,7 @@ import { juniorAgentBindings, juniorAgentInvocations, } from "./schema/agent-invocations"; -import { juniorDestinationConfigurations } from "./schema/destination-configurations"; +import { juniorLocationConfigurations } from "./schema/location-configurations"; import { juniorDestinations } from "./schema/destinations"; import { juniorEventTasks } from "./schema/event-tasks"; import { juniorIdentities } from "./schema/identities"; @@ -27,7 +27,7 @@ export { juniorConversationEvents, juniorConversationBindings, juniorConversations, - juniorDestinationConfigurations, + juniorLocationConfigurations, juniorDestinations, juniorEventTasks, juniorIdentities, @@ -46,7 +46,7 @@ export const juniorSqlSchema = { juniorConversationEvents, juniorConversationBindings, juniorConversations, - juniorDestinationConfigurations, + juniorLocationConfigurations, juniorDestinations, juniorEventTasks, juniorIdentities, diff --git a/packages/junior/src/db/schema/destination-configurations.ts b/packages/junior/src/db/schema/destination-configurations.ts deleted file mode 100644 index b453860376..0000000000 --- a/packages/junior/src/db/schema/destination-configurations.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { jsonb, pgTable, text } from "drizzle-orm/pg-core"; -import type { DestinationConfigState } from "@/chat/configuration/types"; -import { timestamptz } from "./timestamps"; - -/** Durable user configuration scoped to one destination/location. */ -export const juniorDestinationConfigurations = pgTable( - "junior_destination_configurations", - { - destinationKey: text("destination_key").primaryKey(), - configuration: jsonb("configuration_json") - .$type() - .notNull(), - updatedAt: timestamptz("updated_at").notNull(), - }, -); diff --git a/packages/junior/src/db/schema/location-configurations.ts b/packages/junior/src/db/schema/location-configurations.ts new file mode 100644 index 0000000000..50a39851f6 --- /dev/null +++ b/packages/junior/src/db/schema/location-configurations.ts @@ -0,0 +1,18 @@ +import { jsonb, pgTable, text } from "drizzle-orm/pg-core"; +import type { LocationConfigState } from "@/chat/configuration/types"; +import { juniorDestinations } from "./destinations"; +import { timestamptz } from "./timestamps"; + +/** Durable user configuration scoped to one provider Location. */ +export const juniorLocationConfigurations = pgTable( + "junior_location_configurations", + { + locationId: text("location_id") + .primaryKey() + .references(() => juniorDestinations.id, { onDelete: "cascade" }), + configuration: jsonb("configuration_json") + .$type() + .notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, +); diff --git a/packages/junior/src/handlers/mcp-oauth-callback.ts b/packages/junior/src/handlers/mcp-oauth-callback.ts index 0fe8c72c12..3192219579 100644 --- a/packages/junior/src/handlers/mcp-oauth-callback.ts +++ b/packages/junior/src/handlers/mcp-oauth-callback.ts @@ -22,7 +22,7 @@ import { logException, logWarn } from "@/chat/logging"; import type { AgentRunResult } from "@/chat/services/turn-result"; import type { AgentRunner } from "@/chat/runtime/agent-runner"; import { - getDestinationConfigurationService, + getLocationConfigurationService, getPersistedSandboxState, getPersistedThreadState, persistThreadStateById, @@ -343,8 +343,8 @@ async function resumeAuthorizedMcpTurn(args: { excludeMessageId: lockedUserMessage.id, }, ); - const lockedDestinationConfiguration = - getDestinationConfigurationService(destination); + const lockedLocationConfiguration = + getLocationConfigurationService(destination); let actor: Actor; try { actor = createSlackResumeActor({ @@ -414,7 +414,7 @@ async function resumeAuthorizedMcpTurn(args: { }, policy: { configuration: authSession.configuration, - destinationConfiguration: lockedDestinationConfiguration, + locationConfiguration: lockedLocationConfiguration, }, state: { artifactState: lockedArtifacts, diff --git a/packages/junior/src/handlers/oauth-callback.ts b/packages/junior/src/handlers/oauth-callback.ts index 28d875cc70..7f13c64857 100644 --- a/packages/junior/src/handlers/oauth-callback.ts +++ b/packages/junior/src/handlers/oauth-callback.ts @@ -19,7 +19,7 @@ import { persistAuthPauseTurnState } from "@/chat/runtime/auth-pause-state"; import { logException, logInfo, logWarn, withLogContext } from "@/chat/logging"; import { htmlCallbackResponse } from "@/handlers/oauth-html"; import { - getDestinationConfigurationService, + getLocationConfigurationService, getPersistedSandboxState, getPersistedThreadState, persistThreadStateById, @@ -358,8 +358,8 @@ async function resumeOAuthSessionRecordTurn( excludeMessageId: lockedUserMessage.id, }, ); - const lockedDestinationConfiguration = - getDestinationConfigurationService(destination); + const lockedLocationConfiguration = + getLocationConfigurationService(destination); let actor: Actor; try { actor = createSlackResumeActor({ @@ -431,7 +431,7 @@ async function resumeOAuthSessionRecordTurn( lockedArtifacts.assistantContextChannelId ?? stored.channelId!, }, policy: { - destinationConfiguration: lockedDestinationConfiguration, + locationConfiguration: lockedLocationConfiguration, }, state: { artifactState: lockedArtifacts, diff --git a/packages/junior/tests/component/config/destination-configuration-sql.test.ts b/packages/junior/tests/component/config/location-configuration-sql.test.ts similarity index 83% rename from packages/junior/tests/component/config/destination-configuration-sql.test.ts rename to packages/junior/tests/component/config/location-configuration-sql.test.ts index 48010d3ddf..472c73b1f5 100644 --- a/packages/junior/tests/component/config/destination-configuration-sql.test.ts +++ b/packages/junior/tests/component/config/location-configuration-sql.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createDurableDestinationConfigurationService } from "@/chat/configuration/sql"; +import { createDurableLocationConfigurationService } from "@/chat/configuration/sql"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; import { createLocalJuniorSqlFixture } from "../../fixtures/sql"; @@ -25,13 +25,13 @@ function legacyConfiguration(value: string) { }; } -describe("SQL destination configuration", () => { +describe("SQL location configuration", () => { it("persists configuration independently of the legacy cache", async () => { const fixture = await createLocalJuniorSqlFixture(); await migrateSchema(fixture.sql); try { - const service = createDurableDestinationConfigurationService({ + const service = createDurableLocationConfigurationService({ destination: DESTINATION, db: fixture.sql.db(), loadLegacy: async () => null, @@ -42,7 +42,7 @@ describe("SQL destination configuration", () => { updatedBy: "U123", }); - const reloaded = createDurableDestinationConfigurationService({ + const reloaded = createDurableLocationConfigurationService({ destination: DESTINATION, db: fixture.sql.db(), loadLegacy: async () => { @@ -53,7 +53,7 @@ describe("SQL destination configuration", () => { "getsentry/junior", ); await expect(reloaded.get("github.repo")).resolves.toMatchObject({ - scope: "destination", + scope: "location", }); } finally { await fixture.close(); @@ -65,7 +65,7 @@ describe("SQL destination configuration", () => { await migrateSchema(fixture.sql); try { - const service = createDurableDestinationConfigurationService({ + const service = createDurableLocationConfigurationService({ destination: { platform: "slack", teamId: "T-cutover", @@ -78,7 +78,7 @@ describe("SQL destination configuration", () => { "getsentry/legacy", ); - const reloaded = createDurableDestinationConfigurationService({ + const reloaded = createDurableLocationConfigurationService({ destination: { platform: "slack", teamId: "T-cutover", diff --git a/packages/junior/tests/integration/agent-continue-slack.test.ts b/packages/junior/tests/integration/agent-continue-slack.test.ts index 36dac82007..ab62abef57 100644 --- a/packages/junior/tests/integration/agent-continue-slack.test.ts +++ b/packages/junior/tests/integration/agent-continue-slack.test.ts @@ -244,7 +244,7 @@ describe("paused turn Slack integration", () => { }, }); await threadStateModule - .getDestinationConfigurationService(SLACK_DESTINATION) + .getLocationConfigurationService(SLACK_DESTINATION) .set({ key: "demo.org", value: "acme", @@ -283,7 +283,7 @@ describe("paused turn Slack integration", () => { ); const resumeContext = executeAgentRunMock.mock.calls[0]?.[0] as { policy?: { - destinationConfiguration?: { + locationConfiguration?: { resolve: (key: string) => Promise; }; turnDeadlineAtMs?: number; @@ -292,7 +292,7 @@ describe("paused turn Slack integration", () => { expect(resumeContext.policy?.turnDeadlineAtMs).toEqual(expect.any(Number)); expect(resumeContext.policy?.turnDeadlineAtMs).toBeGreaterThan(Date.now()); expect( - await resumeContext.policy?.destinationConfiguration?.resolve("demo.org"), + await resumeContext.policy?.locationConfiguration?.resolve("demo.org"), ).toBe("acme"); expect(slackApiOutbox.calls("assistant.threads.setStatus")).toEqual( diff --git a/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts b/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts index 5e3989a8c0..6f0d2c5706 100644 --- a/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts +++ b/packages/junior/tests/integration/slack/provider-default-config-behavior.test.ts @@ -6,7 +6,7 @@ import { createTestDestination, } from "../../fixtures/slack-harness"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; -import { getDestinationConfigurationService } from "@/chat/runtime/thread-state"; +import { getLocationConfigurationService } from "@/chat/runtime/thread-state"; import { deliverAssistantMessagesForTest } from "../../fixtures/agent-runner"; function toPostedText(value: unknown): string { @@ -51,7 +51,7 @@ describe("Slack behavior: provider default configuration", () => { expect(thread.posts).toHaveLength(1); expect(toPostedText(thread.posts[0])).toContain("getsentry/junior"); await expect( - getDestinationConfigurationService(createTestDestination(thread)).get( + getLocationConfigurationService(createTestDestination(thread)).get( "github.repo", ), ).resolves.toMatchObject({ @@ -104,7 +104,7 @@ describe("Slack behavior: provider default configuration", () => { expect(executeAgentRun).toHaveBeenCalledOnce(); expect(toPostedText(thread.posts[0])).toContain("Created the issue."); await expect( - getDestinationConfigurationService(createTestDestination(thread)).get( + getLocationConfigurationService(createTestDestination(thread)).get( "github.repo", ), ).resolves.toBeUndefined(); diff --git a/packages/junior/tests/unit/config/configuration-service.test.ts b/packages/junior/tests/unit/config/configuration-service.test.ts index f6fe84f962..569b49cbf8 100644 --- a/packages/junior/tests/unit/config/configuration-service.test.ts +++ b/packages/junior/tests/unit/config/configuration-service.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createDestinationConfigurationService } from "@/chat/configuration/service"; +import { createLocationConfigurationService } from "@/chat/configuration/service"; function createInMemoryService() { let state: Record | null = null; - const service = createDestinationConfigurationService({ + const service = createLocationConfigurationService({ load: async () => state, save: async (next) => { state = { @@ -18,7 +18,7 @@ function createInMemoryService() { }; } -describe("destination configuration service", () => { +describe("location configuration service", () => { it("sets, gets, lists, resolves, and unsets entries", async () => { const { service, getState } = createInMemoryService(); @@ -29,7 +29,7 @@ describe("destination configuration service", () => { source: "test", }); expect(created.key).toBe("github.repo"); - expect(created.scope).toBe("destination"); + expect(created.scope).toBe("location"); expect(created.updatedBy).toBe("U123"); expect(created.source).toBe("test"); @@ -72,7 +72,7 @@ describe("destination configuration service", () => { "jira.project": expect.objectContaining({ key: "jira.project", value: "PLAT", - scope: "destination", + scope: "location", }), }, }, diff --git a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts index 5eb1da38c6..00d5415800 100644 --- a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts +++ b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { maybeExecuteJrRpcCustomCommand } from "@/chat/capabilities/jr-rpc-command"; -import { createDestinationConfigurationService } from "@/chat/configuration/service"; +import { createLocationConfigurationService } from "@/chat/configuration/service"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; import type { Skill } from "@/chat/skills"; @@ -12,9 +12,9 @@ const activeSkill: Skill = { pluginProvider: "github", }; -function makeDestinationConfiguration() { +function makeLocationConfiguration() { let state: Record | null = null; - return createDestinationConfigurationService({ + return createLocationConfigurationService({ load: async () => state, save: async (next) => { state = { @@ -64,14 +64,14 @@ describe("jr-rpc custom command", () => { }); it("sets and gets configuration values", async () => { - const configuration = makeDestinationConfiguration(); + const configuration = makeLocationConfiguration(); const onConfigurationValueChanged = vi.fn(); const setResult = await maybeExecuteJrRpcCustomCommand( "jr-rpc config set github.repo getsentry/junior", { activeSkill, - destinationConfiguration: configuration, + locationConfiguration: configuration, actorId: "U123", onConfigurationValueChanged, }, @@ -86,7 +86,7 @@ describe("jr-rpc custom command", () => { "jr-rpc config get github.repo", { activeSkill, - destinationConfiguration: configuration, + locationConfiguration: configuration, actorId: "U123", }, ); @@ -100,7 +100,7 @@ describe("jr-rpc custom command", () => { }); it("supports config list with a prefix filter", async () => { - const configuration = makeDestinationConfiguration(); + const configuration = makeLocationConfiguration(); await configuration.set({ key: "github.repo", value: "getsentry/junior", @@ -118,7 +118,7 @@ describe("jr-rpc custom command", () => { "jr-rpc config list --prefix github.", { activeSkill, - destinationConfiguration: configuration, + locationConfiguration: configuration, actorId: "U123", }, ); @@ -175,7 +175,7 @@ describe("jr-rpc custom command", () => { }); it("unsets configuration values", async () => { - const configuration = makeDestinationConfiguration(); + const configuration = makeLocationConfiguration(); const onConfigurationValueChanged = vi.fn(); await configuration.set({ @@ -189,7 +189,7 @@ describe("jr-rpc custom command", () => { "jr-rpc config unset github.repo", { activeSkill, - destinationConfiguration: configuration, + locationConfiguration: configuration, actorId: "U123", onConfigurationValueChanged, }, @@ -225,12 +225,12 @@ describe("jr-rpc custom command", () => { ], }); try { - const configuration = makeDestinationConfiguration(); + const configuration = makeLocationConfiguration(); const result = await maybeExecuteJrRpcCustomCommand( "jr-rpc config set cloudflare.worker-id sentry-mcp", { activeSkill, - destinationConfiguration: configuration, + locationConfiguration: configuration, actorId: "U123", }, ); @@ -265,12 +265,12 @@ describe("jr-rpc custom command", () => { ], }); try { - const configuration = makeDestinationConfiguration(); + const configuration = makeLocationConfiguration(); const result = await maybeExecuteJrRpcCustomCommand( "jr-rpc config set cloudflare.worker.name sentry-mcp", { activeSkill, - destinationConfiguration: configuration, + locationConfiguration: configuration, actorId: "U123", }, ); From ecd8dad9dfca18d326fb8acc462f4937cbbb62d4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:52:53 +0000 Subject: [PATCH 5/9] fix(chat): Keep concurrent SQL config over legacy cutover Insert Redis cutover rows only when SQL is still empty, then re-read so a concurrent set wins. --- packages/junior/src/chat/configuration/sql.ts | 42 +++++++++++++------ .../config/location-configuration-sql.test.ts | 39 +++++++++++++++++ 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/packages/junior/src/chat/configuration/sql.ts b/packages/junior/src/chat/configuration/sql.ts index 4a8f1d929c..2fa3bd72b4 100644 --- a/packages/junior/src/chat/configuration/sql.ts +++ b/packages/junior/src/chat/configuration/sql.ts @@ -77,22 +77,26 @@ async function resolveLocationId( function createSqlLocationConfigurationStorage( db: JuniorDatabase, destination: Destination, -): LocationConfigurationStorage { +): LocationConfigurationStorage & { + insertIfAbsent: (configuration: LocationConfigState) => Promise; +} { let locationIdPromise: Promise | undefined; const getLocationId = () => (locationIdPromise ??= resolveLocationId(db, destination)); + const load = async () => { + const locationId = await getLocationId(); + const rows = await db + .select({ configuration: juniorLocationConfigurations.configuration }) + .from(juniorLocationConfigurations) + .where(eq(juniorLocationConfigurations.locationId, locationId)) + .limit(1); + const configuration = rows[0]?.configuration; + return configuration ? { configuration } : null; + }; + return { - load: async () => { - const locationId = await getLocationId(); - const rows = await db - .select({ configuration: juniorLocationConfigurations.configuration }) - .from(juniorLocationConfigurations) - .where(eq(juniorLocationConfigurations.locationId, locationId)) - .limit(1); - const configuration = rows[0]?.configuration; - return configuration ? { configuration } : null; - }, + load, save: async (configuration: LocationConfigState) => { const locationId = await getLocationId(); const updatedAt = new Date(); @@ -104,6 +108,17 @@ function createSqlLocationConfigurationStorage( set: { configuration, updatedAt }, }); }, + // Cutover must never overwrite a concurrent SQL write. + insertIfAbsent: async (configuration: LocationConfigState) => { + const locationId = await getLocationId(); + const updatedAt = new Date(); + await db + .insert(juniorLocationConfigurations) + .values({ locationId, configuration, updatedAt }) + .onConflictDoNothing({ + target: juniorLocationConfigurations.locationId, + }); + }, }; } @@ -128,8 +143,9 @@ export function createDurableLocationConfigurationService(args: { if (Object.keys(legacyState.entries).length === 0) { return null; } - await sqlStorage.save(legacyState); - return { configuration: legacyState }; + await sqlStorage.insertIfAbsent(legacyState); + // Prefer any SQL row that landed during the cutover window. + return (await sqlStorage.load()) ?? { configuration: legacyState }; }, save: sqlStorage.save, }); diff --git a/packages/junior/tests/component/config/location-configuration-sql.test.ts b/packages/junior/tests/component/config/location-configuration-sql.test.ts index 472c73b1f5..c6871861fc 100644 --- a/packages/junior/tests/component/config/location-configuration-sql.test.ts +++ b/packages/junior/tests/component/config/location-configuration-sql.test.ts @@ -94,4 +94,43 @@ describe("SQL location configuration", () => { await fixture.close(); } }); + + it("keeps a concurrent SQL write over a stale legacy cutover", async () => { + const fixture = await createLocalJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const destination = { + platform: "slack" as const, + teamId: "T-race", + channelId: "C-race", + }; + + try { + const writer = createDurableLocationConfigurationService({ + destination, + db: fixture.sql.db(), + loadLegacy: async () => null, + }); + const reader = createDurableLocationConfigurationService({ + destination, + db: fixture.sql.db(), + loadLegacy: async () => { + await writer.set({ + key: "github.repo", + value: "getsentry/fresh", + updatedBy: "U-fresh", + }); + return legacyConfiguration("getsentry/stale"); + }, + }); + + await expect(reader.resolve("github.repo")).resolves.toBe( + "getsentry/fresh", + ); + await expect(writer.resolve("github.repo")).resolves.toBe( + "getsentry/fresh", + ); + } finally { + await fixture.close(); + } + }); }); From a821f52c95c3e211840245179e8d3b6392518e51 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:59:24 +0000 Subject: [PATCH 6/9] ref(chat): simplify location configuration cutover Co-Authored-By: David Cramer --- packages/junior/src/chat/configuration/sql.ts | 87 ++++++++----------- .../junior/src/chat/configuration/types.ts | 4 +- .../junior/src/chat/runtime/thread-state.ts | 11 ++- 3 files changed, 42 insertions(+), 60 deletions(-) diff --git a/packages/junior/src/chat/configuration/sql.ts b/packages/junior/src/chat/configuration/sql.ts index 2fa3bd72b4..0af2a493f0 100644 --- a/packages/junior/src/chat/configuration/sql.ts +++ b/packages/junior/src/chat/configuration/sql.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import type { Destination } from "@sentry/junior-plugin-api"; -import { and, eq } from "drizzle-orm"; +import type { SlackDestination } from "@sentry/junior-plugin-api"; +import { and, eq, sql } from "drizzle-orm"; import type { JuniorDatabase } from "@/db/db"; import { juniorDestinations, juniorLocationConfigurations } from "@/db/schema"; import { @@ -10,18 +10,13 @@ import { import type { LocationConfigState, LocationConfigurationService, - LocationConfigurationStorage, } from "@/chat/configuration/types"; /** Resolve the canonical Location row for a supported provider destination. */ async function resolveLocationId( db: JuniorDatabase, - destination: Destination, + destination: SlackDestination, ): Promise { - if (destination.platform !== "slack") { - throw new Error("Location configuration requires a provider Location"); - } - const channelId = destination.channelId; const now = new Date(); const rows = await db @@ -43,53 +38,41 @@ async function resolveLocationId( createdAt: now, updatedAt: now, }) - .onConflictDoNothing({ + .onConflictDoUpdate({ target: [ juniorDestinations.provider, juniorDestinations.providerTenantId, juniorDestinations.providerDestinationId, ], + set: { id: sql`${juniorDestinations.id}` }, }) .returning({ id: juniorDestinations.id }); - const locationId = rows[0]?.id; - if (locationId) { - return locationId; - } - - const existing = await db - .select({ id: juniorDestinations.id }) - .from(juniorDestinations) - .where( - and( - eq(juniorDestinations.provider, "slack"), - eq(juniorDestinations.providerTenantId, destination.teamId), - eq(juniorDestinations.providerDestinationId, channelId), - ), - ) - .limit(1); - if (!existing[0]?.id) { - throw new Error("Location could not be resolved"); - } - return existing[0].id; + return rows[0]!.id; } /** Create durable configuration storage for one Location. */ function createSqlLocationConfigurationStorage( db: JuniorDatabase, - destination: Destination, -): LocationConfigurationStorage & { - insertIfAbsent: (configuration: LocationConfigState) => Promise; -} { - let locationIdPromise: Promise | undefined; - const getLocationId = () => - (locationIdPromise ??= resolveLocationId(db, destination)); - + destination: SlackDestination, +) { const load = async () => { - const locationId = await getLocationId(); const rows = await db .select({ configuration: juniorLocationConfigurations.configuration }) .from(juniorLocationConfigurations) - .where(eq(juniorLocationConfigurations.locationId, locationId)) + .innerJoin( + juniorDestinations, + eq(juniorDestinations.id, juniorLocationConfigurations.locationId), + ) + .where( + and( + eq(juniorDestinations.provider, "slack"), + eq(juniorDestinations.providerTenantId, destination.teamId), + eq( + juniorDestinations.providerDestinationId, + destination.channelId, + ), + ), + ) .limit(1); const configuration = rows[0]?.configuration; return configuration ? { configuration } : null; @@ -98,7 +81,7 @@ function createSqlLocationConfigurationStorage( return { load, save: async (configuration: LocationConfigState) => { - const locationId = await getLocationId(); + const locationId = await resolveLocationId(db, destination); const updatedAt = new Date(); await db .insert(juniorLocationConfigurations) @@ -109,22 +92,26 @@ function createSqlLocationConfigurationStorage( }); }, // Cutover must never overwrite a concurrent SQL write. - insertIfAbsent: async (configuration: LocationConfigState) => { - const locationId = await getLocationId(); - const updatedAt = new Date(); - await db + insertLegacy: async (configuration: LocationConfigState) => { + const locationId = await resolveLocationId(db, destination); + const rows = await db .insert(juniorLocationConfigurations) - .values({ locationId, configuration, updatedAt }) - .onConflictDoNothing({ + .values({ locationId, configuration, updatedAt: new Date() }) + .onConflictDoUpdate({ target: juniorLocationConfigurations.locationId, - }); + set: { + configuration: sql`${juniorLocationConfigurations.configuration}`, + }, + }) + .returning({ configuration: juniorLocationConfigurations.configuration }); + return { configuration: rows[0]!.configuration }; }, }; } /** Resolve SQL-owned Location configuration and copy a live legacy record once. */ export function createDurableLocationConfigurationService(args: { - destination: Destination; + destination: SlackDestination; db: JuniorDatabase; loadLegacy: () => Promise; }): LocationConfigurationService { @@ -143,9 +130,7 @@ export function createDurableLocationConfigurationService(args: { if (Object.keys(legacyState.entries).length === 0) { return null; } - await sqlStorage.insertIfAbsent(legacyState); - // Prefer any SQL row that landed during the cutover window. - return (await sqlStorage.load()) ?? { configuration: legacyState }; + return await sqlStorage.insertLegacy(legacyState); }, save: sqlStorage.save, }); diff --git a/packages/junior/src/chat/configuration/types.ts b/packages/junior/src/chat/configuration/types.ts index 25844217d4..e2f9536072 100644 --- a/packages/junior/src/chat/configuration/types.ts +++ b/packages/junior/src/chat/configuration/types.ts @@ -1,9 +1,7 @@ -export type ConfigScope = "location"; - export interface ConfigEntry { key: string; value: unknown; - scope: ConfigScope; + scope: "location"; updatedAt: string; updatedBy?: string; source?: string; diff --git a/packages/junior/src/chat/runtime/thread-state.ts b/packages/junior/src/chat/runtime/thread-state.ts index 6b053102fb..3fc18de9b5 100644 --- a/packages/junior/src/chat/runtime/thread-state.ts +++ b/packages/junior/src/chat/runtime/thread-state.ts @@ -201,14 +201,13 @@ async function getLegacyChannelState( export function getLocationConfigurationService( destination: Destination, ): LocationConfigurationService { + if (destination.platform !== "slack") { + throw new Error("Location configuration requires a Slack Location"); + } return createDurableLocationConfigurationService({ destination, db: getDb(), - loadLegacy: async () => { - if (destination.platform !== "slack") { - return null; - } - return await getLegacyChannelState(destination.channelId); - }, + loadLegacy: async () => + await getLegacyChannelState(destination.channelId), }); } From fb3c4212c35445cdfa84f7c4955b63ae15d8568d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:05:04 +0000 Subject: [PATCH 7/9] ref(chat): normalize location configuration entries Co-Authored-By: David Cramer --- ...ration.sql => 0024_misty_typhoid_mary.sql} | 11 +- .../junior/migrations/meta/0024_snapshot.json | 44 ++++- packages/junior/migrations/meta/_journal.json | 4 +- .../junior/src/chat/configuration/service.ts | 127 ++++---------- packages/junior/src/chat/configuration/sql.ts | 157 +++++++++++------- .../junior/src/chat/configuration/types.ts | 5 +- .../src/db/schema/location-configurations.ts | 16 +- .../unit/config/configuration-service.test.ts | 75 +++------ .../unit/handlers/jr-rpc-command.test.ts | 13 +- 9 files changed, 219 insertions(+), 233 deletions(-) rename packages/junior/migrations/{0024_location_configuration.sql => 0024_misty_typhoid_mary.sql} (53%) diff --git a/packages/junior/migrations/0024_location_configuration.sql b/packages/junior/migrations/0024_misty_typhoid_mary.sql similarity index 53% rename from packages/junior/migrations/0024_location_configuration.sql rename to packages/junior/migrations/0024_misty_typhoid_mary.sql index 1b271b59ba..a35becfcc9 100644 --- a/packages/junior/migrations/0024_location_configuration.sql +++ b/packages/junior/migrations/0024_misty_typhoid_mary.sql @@ -1,7 +1,12 @@ CREATE TABLE "junior_location_configurations" ( - "location_id" text PRIMARY KEY NOT NULL, - "configuration_json" jsonb NOT NULL, - "updated_at" timestamp with time zone NOT NULL + "location_id" text NOT NULL, + "key" text NOT NULL, + "value" text NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + "updated_by" text, + "source" text, + "expires_at" text, + CONSTRAINT "junior_location_configurations_location_id_key_pk" PRIMARY KEY("location_id","key") ); --> statement-breakpoint ALTER TABLE "junior_location_configurations" ADD CONSTRAINT "junior_location_configurations_location_id_junior_destinations_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."junior_destinations"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0024_snapshot.json b/packages/junior/migrations/meta/0024_snapshot.json index 4f09346bda..ece33a7f1c 100644 --- a/packages/junior/migrations/meta/0024_snapshot.json +++ b/packages/junior/migrations/meta/0024_snapshot.json @@ -1,5 +1,5 @@ { - "id": "b4046f37-2d26-431c-9cb8-ff5f4babce23", + "id": "5b615b01-1e96-4396-a09d-82c0a70f4a58", "prevId": "86dbef33-04cf-4e3d-b324-b321a3af4af8", "version": "7", "dialect": "postgresql", @@ -1596,12 +1596,18 @@ "location_id": { "name": "location_id", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true }, - "configuration_json": { - "name": "configuration_json", - "type": "jsonb", + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", "primaryKey": false, "notNull": true }, @@ -1610,6 +1616,24 @@ "type": "timestamp with time zone", "primaryKey": false, "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false } }, "indexes": {}, @@ -1628,7 +1652,15 @@ "onUpdate": "no action" } }, - "compositePrimaryKeys": {}, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": [ + "location_id", + "key" + ] + } + }, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 9089e5036a..2fa668a218 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -173,8 +173,8 @@ { "idx": 24, "version": "7", - "when": 1786310988956, - "tag": "0024_location_configuration", + "when": 1786313061094, + "tag": "0024_misty_typhoid_mary", "breakpoints": true } ] diff --git a/packages/junior/src/chat/configuration/service.ts b/packages/junior/src/chat/configuration/service.ts index 8891d6b999..c278b1b18f 100644 --- a/packages/junior/src/chat/configuration/service.ts +++ b/packages/junior/src/chat/configuration/service.ts @@ -1,8 +1,8 @@ import type { + ConfigEntry, LocationConfigState, LocationConfigurationService, LocationConfigurationStorage, - ConfigEntry, } from "@/chat/configuration/types"; import { validateConfigKey, @@ -10,30 +10,18 @@ import { } from "@/chat/configuration/validation"; import { isRecord, toOptionalString } from "@/chat/coerce"; -function defaultState(): LocationConfigState { - return { - schemaVersion: 1, - entries: {}, - }; -} - function sanitizeEntry(value: unknown): ConfigEntry | undefined { if (!isRecord(value)) { return undefined; } const key = toOptionalString(value.key); - if (!key) { - return undefined; - } - if (validateConfigKey(key)) { + if (!key || validateConfigKey(key)) { return undefined; } - const updatedAt = toOptionalString(value.updatedAt); if (!updatedAt) { return undefined; } - // Accept legacy "channel" / "conversation" scopes from Redis. if ( value.scope !== "channel" && value.scope !== "conversation" && @@ -41,12 +29,10 @@ function sanitizeEntry(value: unknown): ConfigEntry | undefined { ) { return undefined; } - const scope = "location" as const; - return { key, value: value.value, - scope, + scope: "location", updatedAt, updatedBy: toOptionalString(value.updatedBy), source: toOptionalString(value.source), @@ -54,50 +40,29 @@ function sanitizeEntry(value: unknown): ConfigEntry | undefined { }; } -/** Coerce persisted location configuration into the current durable shape. */ -export function coerceLocationConfigState( - raw: unknown, -): LocationConfigState { - if (!isRecord(raw)) { - return defaultState(); - } - - const rawConfig = isRecord(raw.configuration) ? raw.configuration : {}; +/** Coerce legacy persisted configuration into the current entry shape. */ +export function coerceLocationConfigState(raw: unknown): LocationConfigState { + const rawConfig = isRecord(raw) && isRecord(raw.configuration) + ? raw.configuration + : {}; const rawEntries = isRecord(rawConfig.entries) ? rawConfig.entries : {}; const entries: Record = {}; - for (const [key, value] of Object.entries(rawEntries)) { + for (const value of Object.values(rawEntries)) { const entry = sanitizeEntry(value); - if (!entry) { - continue; + if (entry) { + entries[entry.key] = entry; } - entries[key] = entry; } - - return { - schemaVersion: 1, - entries, - }; + return { schemaVersion: 1, entries }; } +/** Create a Location configuration service over entry-level storage. */ export function createLocationConfigurationService( storage: LocationConfigurationStorage, ): LocationConfigurationService { - const getState = async (): Promise => { - const loaded = await storage.load(); - return coerceLocationConfigState(loaded); - }; - - const saveState = async (state: LocationConfigState): Promise => { - await storage.save({ - schemaVersion: 1, - entries: state.entries, - }); - }; - const get = async (key: string): Promise => { const normalizedKey = key.trim(); - const state = await getState(); - return state.entries[normalizedKey]; + return (await storage.list()).find((entry) => entry.key === normalizedKey); }; const set: LocationConfigurationService["set"] = async (input) => { @@ -106,14 +71,11 @@ export function createLocationConfigurationService( if (keyError) { throw new Error(keyError); } - const valueError = validateConfigValue(input.value); if (valueError) { throw new Error(valueError); } - - const state = await getState(); - const nextEntry: ConfigEntry = { + const entry: ConfigEntry = { key: normalizedKey, value: input.value, scope: "location", @@ -122,65 +84,36 @@ export function createLocationConfigurationService( source: toOptionalString(input.source), expiresAt: toOptionalString(input.expiresAt), }; - state.entries[normalizedKey] = nextEntry; - await saveState(state); - return nextEntry; + await storage.set(entry); + return entry; }; - const unset = async (key: string): Promise => { - const normalizedKey = key.trim(); - const state = await getState(); - if (!state.entries[normalizedKey]) { - return false; - } - delete state.entries[normalizedKey]; - await saveState(state); - return true; - }; + const unset = async (key: string): Promise => + await storage.unset(key.trim()); const list = async ( options: { prefix?: string } = {}, ): Promise => { - const state = await getState(); const prefix = options.prefix?.trim(); - return Object.values(state.entries) + return (await storage.list()) .filter((entry) => (prefix ? entry.key.startsWith(prefix) : true)) .sort((a, b) => a.key.localeCompare(b.key)); }; - const resolve = async (key: string): Promise => { - const entry = await get(key); - return entry?.value; - }; + const resolve = async (key: string): Promise => + (await get(key))?.value; const resolveValues = async ( options: { keys?: string[]; prefix?: string } = {}, ): Promise> => { - const keys = Array.isArray(options.keys) - ? options.keys - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - : undefined; - const entries = options.prefix - ? await list({ prefix: options.prefix }) - : await list({}); - - const filtered = keys - ? entries.filter((entry) => keys.includes(entry.key)) - : entries; - const resolved: Record = {}; - for (const entry of filtered) { - resolved[entry.key] = entry.value; - } - return resolved; + const keys = options.keys?.map((key) => key.trim()).filter(Boolean); + const entries = await list(options.prefix ? { prefix: options.prefix } : {}); + return Object.fromEntries( + entries + .filter((entry) => !keys || keys.includes(entry.key)) + .map((entry) => [entry.key, entry.value]), + ); }; - return { - get, - set, - unset, - list, - resolve, - resolveValues, - }; + return { get, set, unset, list, resolve, resolveValues }; } diff --git a/packages/junior/src/chat/configuration/sql.ts b/packages/junior/src/chat/configuration/sql.ts index 0af2a493f0..dc4ca1c7bd 100644 --- a/packages/junior/src/chat/configuration/sql.ts +++ b/packages/junior/src/chat/configuration/sql.ts @@ -8,11 +8,10 @@ import { createLocationConfigurationService, } from "@/chat/configuration/service"; import type { - LocationConfigState, + ConfigEntry, LocationConfigurationService, } from "@/chat/configuration/types"; -/** Resolve the canonical Location row for a supported provider destination. */ async function resolveLocationId( db: JuniorDatabase, destination: SlackDestination, @@ -50,88 +49,134 @@ async function resolveLocationId( return rows[0]!.id; } -/** Create durable configuration storage for one Location. */ function createSqlLocationConfigurationStorage( db: JuniorDatabase, destination: SlackDestination, ) { - const load = async () => { + const locationWhere = and( + eq(juniorDestinations.provider, "slack"), + eq(juniorDestinations.providerTenantId, destination.teamId), + eq(juniorDestinations.providerDestinationId, destination.channelId), + ); + + const list = async (): Promise => { const rows = await db - .select({ configuration: juniorLocationConfigurations.configuration }) + .select({ + key: juniorLocationConfigurations.key, + value: juniorLocationConfigurations.value, + updatedAt: juniorLocationConfigurations.updatedAt, + updatedBy: juniorLocationConfigurations.updatedBy, + source: juniorLocationConfigurations.source, + expiresAt: juniorLocationConfigurations.expiresAt, + }) .from(juniorLocationConfigurations) .innerJoin( juniorDestinations, eq(juniorDestinations.id, juniorLocationConfigurations.locationId), ) - .where( - and( - eq(juniorDestinations.provider, "slack"), - eq(juniorDestinations.providerTenantId, destination.teamId), - eq( - juniorDestinations.providerDestinationId, - destination.channelId, - ), - ), - ) - .limit(1); - const configuration = rows[0]?.configuration; - return configuration ? { configuration } : null; + .where(locationWhere); + return rows.map((row) => ({ + key: row.key, + value: JSON.parse(row.value) as unknown, + scope: "location", + updatedAt: row.updatedAt.toISOString(), + updatedBy: row.updatedBy ?? undefined, + source: row.source ?? undefined, + expiresAt: row.expiresAt ?? undefined, + })); + }; + + const set = async (entry: ConfigEntry): Promise => { + const locationId = await resolveLocationId(db, destination); + await db + .insert(juniorLocationConfigurations) + .values({ + locationId, + key: entry.key, + value: JSON.stringify(entry.value), + updatedAt: new Date(entry.updatedAt), + updatedBy: entry.updatedBy, + source: entry.source, + expiresAt: entry.expiresAt, + }) + .onConflictDoUpdate({ + target: [ + juniorLocationConfigurations.locationId, + juniorLocationConfigurations.key, + ], + set: { + value: JSON.stringify(entry.value), + updatedAt: new Date(entry.updatedAt), + updatedBy: entry.updatedBy, + source: entry.source, + expiresAt: entry.expiresAt, + }, + }); }; return { - load, - save: async (configuration: LocationConfigState) => { + list, + set, + unset: async (key: string): Promise => { const locationId = await resolveLocationId(db, destination); - const updatedAt = new Date(); - await db - .insert(juniorLocationConfigurations) - .values({ locationId, configuration, updatedAt }) - .onConflictDoUpdate({ - target: juniorLocationConfigurations.locationId, - set: { configuration, updatedAt }, - }); + const rows = await db + .delete(juniorLocationConfigurations) + .where( + and( + eq(juniorLocationConfigurations.locationId, locationId), + eq(juniorLocationConfigurations.key, key), + ), + ) + .returning({ key: juniorLocationConfigurations.key }); + return rows.length > 0; }, - // Cutover must never overwrite a concurrent SQL write. - insertLegacy: async (configuration: LocationConfigState) => { + insertLegacy: async (entries: ConfigEntry[]): Promise => { + if (entries.length === 0) { + return; + } const locationId = await resolveLocationId(db, destination); - const rows = await db + await db .insert(juniorLocationConfigurations) - .values({ locationId, configuration, updatedAt: new Date() }) - .onConflictDoUpdate({ - target: juniorLocationConfigurations.locationId, - set: { - configuration: sql`${juniorLocationConfigurations.configuration}`, - }, - }) - .returning({ configuration: juniorLocationConfigurations.configuration }); - return { configuration: rows[0]!.configuration }; + .values( + entries.map((entry) => ({ + locationId, + key: entry.key, + value: JSON.stringify(entry.value), + updatedAt: new Date(entry.updatedAt), + updatedBy: entry.updatedBy, + source: entry.source, + expiresAt: entry.expiresAt, + })), + ) + .onConflictDoNothing(); }, }; } -/** Resolve SQL-owned Location configuration and copy a live legacy record once. */ +/** Resolve SQL-owned Location configuration and copy live legacy entries once. */ export function createDurableLocationConfigurationService(args: { destination: SlackDestination; db: JuniorDatabase; loadLegacy: () => Promise; }): LocationConfigurationService { - const sqlStorage = createSqlLocationConfigurationStorage( - args.db, - args.destination, - ); - return createLocationConfigurationService({ - load: async () => { - const durable = await sqlStorage.load(); - if (durable) { - return durable; + const storage = createSqlLocationConfigurationStorage(args.db, args.destination); + let cutover: Promise | undefined; + const ensureCutover = () => + (cutover ??= (async () => { + if ((await storage.list()).length > 0) { + return; } // TODO(#1267, v0.147.0): Remove after SQL readers have copied all live 7-day Redis records. - const legacyState = coerceLocationConfigState(await args.loadLegacy()); - if (Object.keys(legacyState.entries).length === 0) { - return null; - } - return await sqlStorage.insertLegacy(legacyState); + const legacy = coerceLocationConfigState(await args.loadLegacy()); + await storage.insertLegacy(Object.values(legacy.entries)); + })()); + + return createLocationConfigurationService({ + list: async () => { + await ensureCutover(); + return await storage.list(); }, - save: sqlStorage.save, + set: storage.set, + unset: storage.unset, }); } diff --git a/packages/junior/src/chat/configuration/types.ts b/packages/junior/src/chat/configuration/types.ts index e2f9536072..f068b74410 100644 --- a/packages/junior/src/chat/configuration/types.ts +++ b/packages/junior/src/chat/configuration/types.ts @@ -14,8 +14,9 @@ export interface LocationConfigState { } export interface LocationConfigurationStorage { - load: () => Promise; - save: (state: LocationConfigState) => Promise; + list: () => Promise; + set: (entry: ConfigEntry) => Promise; + unset: (key: string) => Promise; } export interface LocationConfigurationService { diff --git a/packages/junior/src/db/schema/location-configurations.ts b/packages/junior/src/db/schema/location-configurations.ts index 50a39851f6..9c516d9208 100644 --- a/packages/junior/src/db/schema/location-configurations.ts +++ b/packages/junior/src/db/schema/location-configurations.ts @@ -1,18 +1,20 @@ -import { jsonb, pgTable, text } from "drizzle-orm/pg-core"; -import type { LocationConfigState } from "@/chat/configuration/types"; +import { pgTable, primaryKey, text } from "drizzle-orm/pg-core"; import { juniorDestinations } from "./destinations"; import { timestamptz } from "./timestamps"; -/** Durable user configuration scoped to one provider Location. */ +/** Durable user configuration entries scoped to provider Locations. */ export const juniorLocationConfigurations = pgTable( "junior_location_configurations", { locationId: text("location_id") - .primaryKey() + .notNull() .references(() => juniorDestinations.id, { onDelete: "cascade" }), - configuration: jsonb("configuration_json") - .$type() - .notNull(), + key: text("key").notNull(), + value: text("value").notNull(), updatedAt: timestamptz("updated_at").notNull(), + updatedBy: text("updated_by"), + source: text("source"), + expiresAt: text("expires_at"), }, + (table) => [primaryKey({ columns: [table.locationId, table.key] })], ); diff --git a/packages/junior/tests/unit/config/configuration-service.test.ts b/packages/junior/tests/unit/config/configuration-service.test.ts index 569b49cbf8..b421f71487 100644 --- a/packages/junior/tests/unit/config/configuration-service.test.ts +++ b/packages/junior/tests/unit/config/configuration-service.test.ts @@ -1,26 +1,22 @@ import { describe, expect, it } from "vitest"; import { createLocationConfigurationService } from "@/chat/configuration/service"; +import type { ConfigEntry } from "@/chat/configuration/types"; function createInMemoryService() { - let state: Record | null = null; + const entries = new Map(); const service = createLocationConfigurationService({ - load: async () => state, - save: async (next) => { - state = { - ...(state ?? {}), - configuration: next, - }; + list: async () => [...entries.values()], + set: async (entry) => { + entries.set(entry.key, entry); }, + unset: async (key) => entries.delete(key), }); - return { - service, - getState: () => state, - }; + return { service, entries }; } describe("location configuration service", () => { it("sets, gets, lists, resolves, and unsets entries", async () => { - const { service, getState } = createInMemoryService(); + const { service, entries } = createInMemoryService(); const created = await service.set({ key: "github.repo", @@ -28,26 +24,19 @@ describe("location configuration service", () => { updatedBy: "U123", source: "test", }); - expect(created.key).toBe("github.repo"); - expect(created.scope).toBe("location"); - expect(created.updatedBy).toBe("U123"); - expect(created.source).toBe("test"); - - const fetched = await service.get("github.repo"); - expect(fetched?.value).toBe("getsentry/junior"); - - const listed = await service.list(); - expect(listed).toHaveLength(1); - expect(listed[0]?.key).toBe("github.repo"); - - await service.set({ - key: "jira.project", - value: "PLAT", + expect(created).toMatchObject({ + key: "github.repo", + scope: "location", + updatedBy: "U123", + source: "test", + }); + await expect(service.get("github.repo")).resolves.toMatchObject({ + value: "getsentry/junior", }); - const prefixed = await service.list({ prefix: "github." }); - expect(prefixed).toHaveLength(1); - expect(prefixed[0]?.key).toBe("github.repo"); + await expect(service.list()).resolves.toHaveLength(1); + await service.set({ key: "jira.project", value: "PLAT" }); + await expect(service.list({ prefix: "github." })).resolves.toHaveLength(1); await expect(service.resolve("github.repo")).resolves.toBe( "getsentry/junior", ); @@ -57,38 +46,18 @@ describe("location configuration service", () => { }); await expect( service.resolveValues({ keys: ["jira.project"] }), - ).resolves.toEqual({ - "jira.project": "PLAT", - }); + ).resolves.toEqual({ "jira.project": "PLAT" }); await expect(service.unset("github.repo")).resolves.toBe(true); await expect(service.unset("github.repo")).resolves.toBe(false); - await expect(service.get("github.repo")).resolves.toBeUndefined(); - - expect(getState()).toEqual({ - configuration: { - schemaVersion: 1, - entries: { - "jira.project": expect.objectContaining({ - key: "jira.project", - value: "PLAT", - scope: "location", - }), - }, - }, - }); + expect(entries.has("github.repo")).toBe(false); }); it("rejects invalid keys and secret-like values", async () => { const { service } = createInMemoryService(); - await expect( - service.set({ - key: "token.value", - value: "abc", - }), + service.set({ key: "token.value", value: "abc" }), ).rejects.toThrow("secret-related"); - await expect( service.set({ key: "github.repo", diff --git a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts index 00d5415800..44ff6b7bfb 100644 --- a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts +++ b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { maybeExecuteJrRpcCustomCommand } from "@/chat/capabilities/jr-rpc-command"; import { createLocationConfigurationService } from "@/chat/configuration/service"; +import type { ConfigEntry } from "@/chat/configuration/types"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; import type { Skill } from "@/chat/skills"; @@ -13,15 +14,13 @@ const activeSkill: Skill = { }; function makeLocationConfiguration() { - let state: Record | null = null; + const entries = new Map(); return createLocationConfigurationService({ - load: async () => state, - save: async (next) => { - state = { - ...(state ?? {}), - configuration: next, - }; + list: async () => [...entries.values()], + set: async (entry) => { + entries.set(entry.key, entry); }, + unset: async (key) => entries.delete(key), }); } From e9e08d8cdde8fbed3b943ebffb6a1bfd33c41f61 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:19:02 +0000 Subject: [PATCH 8/9] ref(chat): align location config with repo policy Co-Authored-By: David Cramer --- .../migrations/0024_misty_typhoid_mary.sql | 2 +- .../junior/src/chat/configuration/service.ts | 21 +++---- packages/junior/src/chat/configuration/sql.ts | 61 +++++++++++++------ .../junior/src/chat/configuration/types.ts | 9 ++- .../unit/config/configuration-service.test.ts | 1 + .../unit/handlers/jr-rpc-command.test.ts | 1 + 6 files changed, 55 insertions(+), 40 deletions(-) diff --git a/packages/junior/migrations/0024_misty_typhoid_mary.sql b/packages/junior/migrations/0024_misty_typhoid_mary.sql index a35becfcc9..152bf30f3c 100644 --- a/packages/junior/migrations/0024_misty_typhoid_mary.sql +++ b/packages/junior/migrations/0024_misty_typhoid_mary.sql @@ -9,4 +9,4 @@ CREATE TABLE "junior_location_configurations" ( CONSTRAINT "junior_location_configurations_location_id_key_pk" PRIMARY KEY("location_id","key") ); --> statement-breakpoint -ALTER TABLE "junior_location_configurations" ADD CONSTRAINT "junior_location_configurations_location_id_junior_destinations_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."junior_destinations"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file +ALTER TABLE "junior_location_configurations" ADD CONSTRAINT "junior_location_configurations_location_id_junior_destinations_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."junior_destinations"("id") ON DELETE cascade ON UPDATE no action; diff --git a/packages/junior/src/chat/configuration/service.ts b/packages/junior/src/chat/configuration/service.ts index c278b1b18f..9c123f376d 100644 --- a/packages/junior/src/chat/configuration/service.ts +++ b/packages/junior/src/chat/configuration/service.ts @@ -1,6 +1,5 @@ import type { ConfigEntry, - LocationConfigState, LocationConfigurationService, LocationConfigurationStorage, } from "@/chat/configuration/types"; @@ -40,30 +39,24 @@ function sanitizeEntry(value: unknown): ConfigEntry | undefined { }; } -/** Coerce legacy persisted configuration into the current entry shape. */ -export function coerceLocationConfigState(raw: unknown): LocationConfigState { +/** Coerce legacy persisted configuration into current Location entries. */ +export function coerceLegacyLocationConfig(raw: unknown): ConfigEntry[] { const rawConfig = isRecord(raw) && isRecord(raw.configuration) ? raw.configuration : {}; const rawEntries = isRecord(rawConfig.entries) ? rawConfig.entries : {}; - const entries: Record = {}; - for (const value of Object.values(rawEntries)) { + return Object.values(rawEntries).flatMap((value) => { const entry = sanitizeEntry(value); - if (entry) { - entries[entry.key] = entry; - } - } - return { schemaVersion: 1, entries }; + return entry ? [entry] : []; + }); } /** Create a Location configuration service over entry-level storage. */ export function createLocationConfigurationService( storage: LocationConfigurationStorage, ): LocationConfigurationService { - const get = async (key: string): Promise => { - const normalizedKey = key.trim(); - return (await storage.list()).find((entry) => entry.key === normalizedKey); - }; + const get = async (key: string): Promise => + await storage.get(key.trim()); const set: LocationConfigurationService["set"] = async (input) => { const normalizedKey = input.key.trim(); diff --git a/packages/junior/src/chat/configuration/sql.ts b/packages/junior/src/chat/configuration/sql.ts index dc4ca1c7bd..d756b131c7 100644 --- a/packages/junior/src/chat/configuration/sql.ts +++ b/packages/junior/src/chat/configuration/sql.ts @@ -4,7 +4,7 @@ import { and, eq, sql } from "drizzle-orm"; import type { JuniorDatabase } from "@/db/db"; import { juniorDestinations, juniorLocationConfigurations } from "@/db/schema"; import { - coerceLocationConfigState, + coerceLegacyLocationConfig, createLocationConfigurationService, } from "@/chat/configuration/service"; import type { @@ -12,6 +12,7 @@ import type { LocationConfigurationService, } from "@/chat/configuration/types"; +/** Resolve or create the canonical Location for a Slack Destination. */ async function resolveLocationId( db: JuniorDatabase, destination: SlackDestination, @@ -49,6 +50,7 @@ async function resolveLocationId( return rows[0]!.id; } +/** Persist configuration rows for the Location addressed by a Slack Destination. */ function createSqlLocationConfigurationStorage( db: JuniorDatabase, destination: SlackDestination, @@ -59,8 +61,8 @@ function createSqlLocationConfigurationStorage( eq(juniorDestinations.providerDestinationId, destination.channelId), ); - const list = async (): Promise => { - const rows = await db + const selectEntries = () => + db .select({ key: juniorLocationConfigurations.key, value: juniorLocationConfigurations.value, @@ -73,18 +75,21 @@ function createSqlLocationConfigurationStorage( .innerJoin( juniorDestinations, eq(juniorDestinations.id, juniorLocationConfigurations.locationId), - ) - .where(locationWhere); - return rows.map((row) => ({ - key: row.key, - value: JSON.parse(row.value) as unknown, - scope: "location", - updatedAt: row.updatedAt.toISOString(), - updatedBy: row.updatedBy ?? undefined, - source: row.source ?? undefined, - expiresAt: row.expiresAt ?? undefined, - })); - }; + ); + const entryFromRow = ( + row: Awaited>[number], + ): ConfigEntry => ({ + key: row.key, + value: JSON.parse(row.value) as unknown, + scope: "location", + updatedAt: row.updatedAt.toISOString(), + updatedBy: row.updatedBy ?? undefined, + source: row.source ?? undefined, + expiresAt: row.expiresAt ?? undefined, + }); + + const list = async (): Promise => + (await selectEntries().where(locationWhere)).map(entryFromRow); const set = async (entry: ConfigEntry): Promise => { const locationId = await resolveLocationId(db, destination); @@ -115,6 +120,12 @@ function createSqlLocationConfigurationStorage( }; return { + get: async (key: string): Promise => { + const rows = await selectEntries() + .where(and(locationWhere, eq(juniorLocationConfigurations.key, key))) + .limit(1); + return rows[0] ? entryFromRow(rows[0]) : undefined; + }, list, set, unset: async (key: string): Promise => { @@ -166,17 +177,27 @@ export function createDurableLocationConfigurationService(args: { if ((await storage.list()).length > 0) { return; } - // TODO(#1267, v0.147.0): Remove after SQL readers have copied all live 7-day Redis records. - const legacy = coerceLocationConfigState(await args.loadLegacy()); - await storage.insertLegacy(Object.values(legacy.entries)); + // TODO(v0.147.0): Remove the 7-day Redis Location configuration import. + const legacy = coerceLegacyLocationConfig(await args.loadLegacy()); + await storage.insertLegacy(legacy); })()); return createLocationConfigurationService({ + get: async (key) => { + await ensureCutover(); + return await storage.get(key); + }, list: async () => { await ensureCutover(); return await storage.list(); }, - set: storage.set, - unset: storage.unset, + set: async (entry) => { + await ensureCutover(); + await storage.set(entry); + }, + unset: async (key) => { + await ensureCutover(); + return await storage.unset(key); + }, }); } diff --git a/packages/junior/src/chat/configuration/types.ts b/packages/junior/src/chat/configuration/types.ts index f068b74410..8b86531249 100644 --- a/packages/junior/src/chat/configuration/types.ts +++ b/packages/junior/src/chat/configuration/types.ts @@ -1,3 +1,4 @@ +/** One user-authored configuration value scoped to a Location. */ export interface ConfigEntry { key: string; value: unknown; @@ -8,17 +9,15 @@ export interface ConfigEntry { expiresAt?: string; } -export interface LocationConfigState { - schemaVersion: 1; - entries: Record; -} - +/** Entry-level persistence used by Location configuration. */ export interface LocationConfigurationStorage { + get: (key: string) => Promise; list: () => Promise; set: (entry: ConfigEntry) => Promise; unset: (key: string) => Promise; } +/** Read and update user-authored configuration for one Location. */ export interface LocationConfigurationService { get: (key: string) => Promise; set: (input: { diff --git a/packages/junior/tests/unit/config/configuration-service.test.ts b/packages/junior/tests/unit/config/configuration-service.test.ts index b421f71487..6db47269f9 100644 --- a/packages/junior/tests/unit/config/configuration-service.test.ts +++ b/packages/junior/tests/unit/config/configuration-service.test.ts @@ -5,6 +5,7 @@ import type { ConfigEntry } from "@/chat/configuration/types"; function createInMemoryService() { const entries = new Map(); const service = createLocationConfigurationService({ + get: async (key) => entries.get(key), list: async () => [...entries.values()], set: async (entry) => { entries.set(entry.key, entry); diff --git a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts index 44ff6b7bfb..a3a341d100 100644 --- a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts +++ b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts @@ -16,6 +16,7 @@ const activeSkill: Skill = { function makeLocationConfiguration() { const entries = new Map(); return createLocationConfigurationService({ + get: async (key) => entries.get(key), list: async () => [...entries.values()], set: async (entry) => { entries.set(entry.key, entry); From acd4bee9ea48cb3fc8b1b208c83c708eac065d71 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:23:56 +0000 Subject: [PATCH 9/9] fix(chat): validate location config values Co-Authored-By: David Cramer --- .../junior/src/chat/configuration/service.ts | 16 +++++++++++----- packages/junior/src/chat/configuration/sql.ts | 9 +++++++-- packages/junior/src/chat/configuration/types.ts | 12 +++++++++--- packages/junior/src/chat/runtime/slack-resume.ts | 16 +++++++++++++--- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/junior/src/chat/configuration/service.ts b/packages/junior/src/chat/configuration/service.ts index 9c123f376d..c1daa1c3e3 100644 --- a/packages/junior/src/chat/configuration/service.ts +++ b/packages/junior/src/chat/configuration/service.ts @@ -1,3 +1,4 @@ +import { configValueSchema } from "@/chat/configuration/types"; import type { ConfigEntry, LocationConfigurationService, @@ -28,9 +29,13 @@ function sanitizeEntry(value: unknown): ConfigEntry | undefined { ) { return undefined; } + const parsedValue = configValueSchema.safeParse(value.value); + if (!parsedValue.success) { + return undefined; + } return { key, - value: value.value, + value: parsedValue.data, scope: "location", updatedAt, updatedBy: toOptionalString(value.updatedBy), @@ -64,13 +69,14 @@ export function createLocationConfigurationService( if (keyError) { throw new Error(keyError); } - const valueError = validateConfigValue(input.value); + const value = configValueSchema.parse(input.value); + const valueError = validateConfigValue(value); if (valueError) { throw new Error(valueError); } const entry: ConfigEntry = { key: normalizedKey, - value: input.value, + value, scope: "location", updatedAt: new Date().toISOString(), updatedBy: toOptionalString(input.updatedBy), @@ -93,12 +99,12 @@ export function createLocationConfigurationService( .sort((a, b) => a.key.localeCompare(b.key)); }; - const resolve = async (key: string): Promise => + const resolve: LocationConfigurationService["resolve"] = async (key) => (await get(key))?.value; const resolveValues = async ( options: { keys?: string[]; prefix?: string } = {}, - ): Promise> => { + ): ReturnType => { const keys = options.keys?.map((key) => key.trim()).filter(Boolean); const entries = await list(options.prefix ? { prefix: options.prefix } : {}); return Object.fromEntries( diff --git a/packages/junior/src/chat/configuration/sql.ts b/packages/junior/src/chat/configuration/sql.ts index d756b131c7..843734459d 100644 --- a/packages/junior/src/chat/configuration/sql.ts +++ b/packages/junior/src/chat/configuration/sql.ts @@ -7,6 +7,7 @@ import { coerceLegacyLocationConfig, createLocationConfigurationService, } from "@/chat/configuration/service"; +import { configValueSchema } from "@/chat/configuration/types"; import type { ConfigEntry, LocationConfigurationService, @@ -47,7 +48,11 @@ async function resolveLocationId( set: { id: sql`${juniorDestinations.id}` }, }) .returning({ id: juniorDestinations.id }); - return rows[0]!.id; + const location = rows[0]; + if (!location) { + throw new Error("Location upsert returned no row"); + } + return location.id; } /** Persist configuration rows for the Location addressed by a Slack Destination. */ @@ -80,7 +85,7 @@ function createSqlLocationConfigurationStorage( row: Awaited>[number], ): ConfigEntry => ({ key: row.key, - value: JSON.parse(row.value) as unknown, + value: configValueSchema.parse(JSON.parse(row.value)), scope: "location", updatedAt: row.updatedAt.toISOString(), updatedBy: row.updatedBy ?? undefined, diff --git a/packages/junior/src/chat/configuration/types.ts b/packages/junior/src/chat/configuration/types.ts index 8b86531249..98b1a4fe85 100644 --- a/packages/junior/src/chat/configuration/types.ts +++ b/packages/junior/src/chat/configuration/types.ts @@ -1,7 +1,13 @@ +import { z } from "zod"; + +/** JSON value accepted by Location configuration. */ +export const configValueSchema = z.json(); +export type ConfigValue = z.output; + /** One user-authored configuration value scoped to a Location. */ export interface ConfigEntry { key: string; - value: unknown; + value: ConfigValue; scope: "location"; updatedAt: string; updatedBy?: string; @@ -29,9 +35,9 @@ export interface LocationConfigurationService { }) => Promise; unset: (key: string) => Promise; list: (options?: { prefix?: string }) => Promise; - resolve: (key: string) => Promise; + resolve: (key: string) => Promise; resolveValues: (options?: { keys?: string[]; prefix?: string; - }) => Promise>; + }) => Promise>; } diff --git a/packages/junior/src/chat/runtime/slack-resume.ts b/packages/junior/src/chat/runtime/slack-resume.ts index 10c853b8d1..a31288787c 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -8,7 +8,11 @@ import type { ReplyAttribution } from "@sentry/junior-plugin-api"; import { botConfig } from "@/chat/config"; import { standardModelId } from "@/chat/model-profile"; -import type { LocationConfigurationService } from "@/chat/configuration/types"; +import { configValueSchema } from "@/chat/configuration/types"; +import type { + ConfigValue, + LocationConfigurationService, +} from "@/chat/configuration/types"; import { RetryableDeliveryError, type AgentRunRequest, @@ -117,8 +121,14 @@ async function postSlackMessageBestEffort( /** Create a read-only configuration service from persisted values. */ function createReadOnlyConfigService( - values: Record, + rawValues: Record, ): LocationConfigurationService { + const values: Record = Object.fromEntries( + Object.entries(rawValues).map(([key, value]) => [ + key, + configValueSchema.parse(value), + ]), + ); const entries = Object.entries(values).map(([key, value]) => ({ key, value, @@ -136,7 +146,7 @@ function createReadOnlyConfigService( entries.filter((entry) => !prefix || entry.key.startsWith(prefix)), resolve: async (key) => values[key], resolveValues: async ({ keys, prefix } = {}) => { - const filtered: Record = {}; + const filtered: Record = {}; for (const [key, value] of Object.entries(values)) { if (prefix && !key.startsWith(prefix)) continue; if (keys && !keys.includes(key)) continue;