diff --git a/apps/cloud/drizzle/0005_integration_descriptions.sql b/apps/cloud/drizzle/0005_integration_descriptions.sql new file mode 100644 index 000000000..a7da479a2 --- /dev/null +++ b/apps/cloud/drizzle/0005_integration_descriptions.sql @@ -0,0 +1,10 @@ +ALTER TABLE "integration" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "connection" ADD COLUMN "description" text;--> statement-breakpoint +ALTER TABLE "connection" ADD COLUMN "tools_synced_at" bigint;--> statement-breakpoint +ALTER TABLE "integration" ADD COLUMN "name" text;--> statement-breakpoint +ALTER TABLE "integration" ADD COLUMN "config_revised_at" bigint;--> statement-breakpoint +-- Pre-split rows kept the display name in `description`. Move it to `name` +-- (its proper home), then clear `description` so it no longer carries a +-- duplicated title — it now means an actual, optional description. +UPDATE "integration" SET "name" = "description" WHERE "name" IS NULL;--> statement-breakpoint +UPDATE "integration" SET "description" = NULL; diff --git a/apps/cloud/drizzle/meta/0005_snapshot.json b/apps/cloud/drizzle/meta/0005_snapshot.json new file mode 100644 index 000000000..8ee3d48f5 --- /dev/null +++ b/apps/cloud/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1266 @@ +{ + "id": "e105c61d-2fde-4d2e-a785-a74fafd0f4ea", + "prevId": "1187b7aa-fdd6-412a-bcfb-20d6ea8f8644", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "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": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 7d2353b9d..259a1b4a4 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1781375837041, "tag": "0004_nervous_quasar", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1781491100009, + "tag": "0005_integration_descriptions", + "breakpoints": true } ] } diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 37b6f0dd2..a233d1f95 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -76,6 +76,7 @@ "sonner": "^2.0.7" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.15.0", "@cloudflare/workers-types": "^4.20250620.0", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index ad0839cc1..214fbda5f 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -15,7 +15,9 @@ export const integration = pgTable( { slug: varchar("slug", { length: 255 }).notNull(), plugin_id: text("plugin_id").notNull(), - description: text("description").notNull(), + name: text("name"), + description: text("description"), + config_revised_at: bigint("config_revised_at", { mode: "bigint" }), config: json("config"), can_remove: boolean("can_remove").notNull().default(true), can_refresh: boolean("can_refresh").notNull().default(false), @@ -39,6 +41,8 @@ export const connection = pgTable( provider: text("provider").notNull(), item_ids: json("item_ids").notNull(), identity_label: text("identity_label"), + description: text("description"), + tools_synced_at: bigint("tools_synced_at", { mode: "bigint" }), oauth_client: text("oauth_client"), oauth_client_owner: text("oauth_client_owner"), refresh_item_id: text("refresh_item_id"), diff --git a/apps/local/src/db/sqlite-fumadb.ts b/apps/local/src/db/sqlite-fumadb.ts index e38639d70..5bee04a1c 100644 --- a/apps/local/src/db/sqlite-fumadb.ts +++ b/apps/local/src/db/sqlite-fumadb.ts @@ -4,7 +4,7 @@ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; import { type FumaDB } from "@executor-js/fumadb"; import { createDrizzleRuntimeSchemaFromTables, - createDrizzleRuntimeSchemaSqlFromTables, + ensureDrizzleRuntimeSchemaFromTables, } from "@executor-js/fumadb/adapters/drizzle"; import { type schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema"; @@ -49,18 +49,23 @@ export const createSqliteFumaDb = async ( }); const drizzleDb = drizzle({ client, schema }); - for (const statement of createDrizzleRuntimeSchemaSqlFromTables({ + // CREATE TABLE IF NOT EXISTS for fresh files, plus ALTER TABLE ADD COLUMN for + // every nullable column the running schema declares — so files created by an + // earlier baseline gain new columns on boot instead of 500ing on first query. + // This is the same bring-up the self-host and Cloudflare hosts run; keeping + // local on it stops per-column drift between hosts. Idempotent. + await ensureDrizzleRuntimeSchemaFromTables(drizzleDb, { tables: options.tables, namespace: options.namespace, version, provider: "sqlite", - })) { - await client.execute(statement); - } + }); - // Defensive column adds for libSQL files created by earlier v2 baselines — - // the bring-up above is CREATE TABLE IF NOT EXISTS and won't add a column to - // an already-created table. Idempotent. + // Defensive column adds for libSQL files created by earlier v2 baselines. + // The generic bring-up above already evolves every nullable column the live + // schema declares, so these are mostly redundant now; they are kept as an + // explicit safety net and to cover any column the schema no longer declares. + // Idempotent. const connectionColumns = await client.execute("PRAGMA table_info('connection')"); if ( connectionColumns.rows.length > 0 && diff --git a/bun.lock b/bun.lock index a5c103cf5..ca94703ed 100644 --- a/bun.lock +++ b/bun.lock @@ -100,6 +100,7 @@ "sonner": "^2.0.7", }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.15.0", "@cloudflare/workers-types": "^4.20250620.0", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", @@ -349,6 +350,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", "typescript": "catalog:", "vite": "catalog:", @@ -522,6 +524,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@libsql/client": "catalog:", "@types/better-sqlite3": "^7.6.13", "@types/node": "catalog:", "@types/pg": "^8.20.0", @@ -4300,7 +4303,7 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="], + "miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -5400,8 +5403,6 @@ "@cloudflare/vitest-pool-workers/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - "@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="], - "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="], "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -6020,7 +6021,7 @@ "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], - "miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], @@ -6144,6 +6145,8 @@ "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "wrangler/miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -6224,10 +6227,6 @@ "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], - - "@cloudflare/vitest-pool-workers/miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], - "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], @@ -6678,6 +6677,16 @@ "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], + + "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], + + "miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], + + "miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], + + "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -6774,6 +6783,10 @@ "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "wrangler/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + + "wrangler/miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -6854,16 +6867,6 @@ "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ=="], - "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], - - "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], - - "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], - - "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], - - "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], - "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], diff --git a/e2e/cloud/spec-update-convergence.test.ts b/e2e/cloud/spec-update-convergence.test.ts new file mode 100644 index 000000000..2a20630df --- /dev/null +++ b/e2e/cloud/spec-update-convergence.test.ts @@ -0,0 +1,245 @@ +// Cloud-only (needs real multi-user organizations): when one member refreshes +// a shared integration's spec, a DIFFERENT member's OWN connection converges to +// the new tool catalog on that member's next read — not just the editor's. +// +// This is the lazy-convergence design. Tools are stored per connection. The +// editor stamps the integration's `config_revised_at`, but the owner policy lets +// them rebuild catalogs only in their own partition — they cannot write a +// co-worker's personal connection rows. So each member's connection carries a +// `tools_synced_at` and rebuilds itself the next time THAT member lists tools. +// Without it, a colleague keeps calling tools that the API no longer serves. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; +import type { Identity, Target as TargetShape } from "../src/target"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; + +// Same evolving spec the single-user openapi-update-spec scenario uses, so the +// expected tool names below are already an independently-verified contract. +const specV1 = JSON.stringify({ + openapi: "3.0.3", + info: { title: "Evolving API", version: "1.0.0" }, + paths: { + "/ping": { + get: { + operationId: "ping", + summary: "Return a pong", + responses: { "200": { description: "pong" } }, + }, + }, + "/legacy": { + get: { + operationId: "legacyOp", + summary: "Soon to be removed", + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); + +const specV2 = JSON.stringify({ + openapi: "3.0.3", + info: { title: "Evolving API", version: "2.0.0" }, + paths: { + "/ping": { + get: { + operationId: "ping", + summary: "Return a pong", + responses: { "200": { description: "pong" } }, + }, + }, + "/widgets": { + get: { + operationId: "listWidgets", + summary: "List widgets", + responses: { "200": { description: "widgets" } }, + }, + }, + }, +}); + +const V1_TOOLS = ["legacy.legacyOp", "ping.getOperation"]; +const V2_TOOLS = ["ping.getOperation", "widgets.listWidgets"]; + +/** A real 127.0.0.1 server whose served spec can be swapped mid-scenario. */ +const serveMutableSpec = (initial: string) => + Effect.acquireRelease( + Effect.callback<{ + readonly url: string; + readonly setBody: (body: string) => void; + readonly close: () => void; + }>((resume) => { + let body = initial; + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(body); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}/spec.json`, + setBody: (next: string) => { + body = next; + }, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +// ── Session plumbing over the real auth endpoints (mirrors the web app) ────── +const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? ""; + +const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) => + Effect.promise(async () => { + const response = await fetch(new URL(path, target.baseUrl), { + method: "POST", + headers: { + "content-type": "application/json", + origin: new URL(target.baseUrl).origin, + cookie: cookieOf(identity), + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`${path} failed (${response.status}): ${await response.text()}`); + } + return response; + }); + +const withRefreshedSession = (identity: Identity, response: Response): Identity => { + const refreshed = (response.headers.getSetCookie?.() ?? []) + .find((header) => header.startsWith("wos-session=")) + ?.split(";")[0]; + if (!refreshed) throw new Error("response did not refresh the session cookie"); + return { ...identity, headers: { cookie: refreshed } }; +}; + +/** Invite `member` into `admin`'s org and accept — the real invite flow. */ +const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => + Effect.gen(function* () { + const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { + email: member.credentials?.email, + }); + const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; + const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { + invitationId: invitation.id, + }); + return withRefreshedSession(member, acceptResponse); + }); + +const apiKeyTemplate = [ + { + slug: "apiKey", + type: "apiKey" as const, + headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] }, + }, +]; + +const personalConnection = (client: Client, integration: IntegrationSlug, name: ConnectionName) => + client.connections.create({ + payload: { + owner: "user", + name, + integration, + template: AuthTemplateSlug.make("apiKey"), + value: `tok-${randomBytes(8).toString("hex")}`, + }, + }); + +const ownToolNames = (client: Client, integration: IntegrationSlug) => + Effect.map(client.tools.list({ query: { integration, owner: "user" } }), (tools) => + tools.map((tool) => tool.name).sort(), + ); + +scenario( + "Convergence · a spec refresh reaches a co-worker's own connection on their next read", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client } = yield* Api; + + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const colleague = yield* joinOrg(target, admin, invitee); + + const adminClient = yield* client(api, admin); + const colleagueClient = yield* client(api, colleague); + + const slug = IntegrationSlug.make(`converge-${randomBytes(4).toString("hex")}`); + const adminConn = ConnectionName.make(`admin${randomBytes(3).toString("hex")}`); + const colleagueConn = ConnectionName.make(`peer${randomBytes(3).toString("hex")}`); + const specServer = yield* serveMutableSpec(specV1); + + yield* Effect.ensuring( + Effect.gen(function* () { + // The admin registers a SHARED (org) integration from the live spec. + yield* adminClient.openapi.addSpec({ + payload: { + spec: { kind: "url", url: specServer.url }, + slug, + baseUrl: "http://127.0.0.1:59999", // tools are never invoked here + authenticationTemplate: apiKeyTemplate, + }, + }); + + // Each member binds their OWN personal connection to it. + yield* personalConnection(adminClient, slug, adminConn); + yield* personalConnection(colleagueClient, slug, colleagueConn); + + // Both see the v1 catalog on their own connection to start. + expect(yield* ownToolNames(adminClient, slug), "admin starts on v1").toEqual(V1_TOOLS); + expect(yield* ownToolNames(colleagueClient, slug), "the co-worker starts on v1").toEqual( + V1_TOOLS, + ); + + // The upstream API ships v2; the admin refreshes the shared spec. + specServer.setBody(specV2); + const updated = yield* adminClient.openapi.updateSpec({ params: { slug }, payload: {} }); + expect(updated.addedTools, "the diff names the new tool").toEqual([ + "widgets.listWidgets", + ]); + + // The editor's own connection follows immediately. + expect(yield* ownToolNames(adminClient, slug), "admin converged to v2").toEqual(V2_TOOLS); + + // The co-worker did nothing but read — yet their personal connection + // has converged to v2, even though the admin could never write the + // co-worker's rows. This is the lazy sync on the colleague's read. + expect( + yield* ownToolNames(colleagueClient, slug), + "the co-worker converged to v2 on their next read", + ).toEqual(V2_TOOLS); + }), + Effect.gen(function* () { + yield* colleagueClient.connections + .remove({ params: { owner: "user", integration: slug, name: colleagueConn } }) + .pipe(Effect.ignore); + yield* adminClient.connections + .remove({ params: { owner: "user", integration: slug, name: adminConn } }) + .pipe(Effect.ignore); + yield* adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/e2e/package.json b/e2e/package.json index 3e16ddd4b..022900cc9 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -43,6 +43,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", "typescript": "catalog:", "vite": "catalog:", diff --git a/e2e/scenarios/metadata-editing.test.ts b/e2e/scenarios/metadata-editing.test.ts new file mode 100644 index 000000000..57d6e7f49 --- /dev/null +++ b/e2e/scenarios/metadata-editing.test.ts @@ -0,0 +1,120 @@ +// Cross-target: the curate-your-catalog promise. An integration's display +// name and description, and a connection's description, are user-editable +// metadata — set them after the fact and they persist and read back. This is +// the write half of the descriptions feature; openapi-update-spec covers the +// half where a curated description SURVIVES a spec refresh. +import { randomBytes, randomUUID } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +/** Minimal apiKey-authenticated spec — a connection can bind to it; the single + * operation is never invoked here. */ +const pingSpec = JSON.stringify({ + openapi: "3.0.3", + info: { title: "Ping API", version: "1.0.0" }, + paths: { + "/ping": { + get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, + }, + }, +}); + +scenario( + "Metadata · an integration's name/description and a connection's description are editable", + {}, + Effect.gen(function* () { + const target = yield* Target; + const { client } = yield* Api; + const apiClient = yield* client(api, yield* target.newIdentity()); + + const slug = IntegrationSlug.make(`meta-edit-${randomBytes(4).toString("hex")}`); + const connectionName = ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* apiClient.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: pingSpec }, + slug, + baseUrl: "http://127.0.0.1:59999", // never contacted + description: "Auto-derived at add time.", + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + + // The add-time description is what the user first sees. + const initial = yield* apiClient.integrations.get({ params: { slug } }); + expect(initial.description, "the add-time description is stored").toBe( + "Auto-derived at add time.", + ); + + // Rename and re-describe the integration — the curate step. + const renamed = yield* apiClient.integrations.update({ + params: { slug }, + payload: { name: "Acme Ping", description: "Hand-curated for the team." }, + }); + expect(renamed.name, "the update response carries the new name").toBe("Acme Ping"); + expect(renamed.description, "the update response carries the new description").toBe( + "Hand-curated for the team.", + ); + + // It persists for the next reader, not just in the response. + const reread = yield* apiClient.integrations.get({ params: { slug } }); + expect(reread.name, "the name persisted").toBe("Acme Ping"); + expect(reread.description, "the description persisted").toBe("Hand-curated for the team."); + + // A connection carries its own editable description. + const providers = yield* apiClient.providers.list(); + yield* apiClient.connections.create({ + payload: { + owner: "org", + name: connectionName, + integration: slug, + template: AuthTemplateSlug.make("apiKey"), + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, + }, + }); + const describedConnection = yield* apiClient.connections.update({ + params: { owner: "org", integration: slug, name: connectionName }, + payload: { description: "Prod key, rotates quarterly." }, + }); + expect(describedConnection.description, "the connection description was set").toBe( + "Prod key, rotates quarterly.", + ); + + const connectionReread = yield* apiClient.connections.get({ + params: { owner: "org", integration: slug, name: connectionName }, + }); + expect(connectionReread.description, "the connection description persisted").toBe( + "Prod key, rotates quarterly.", + ); + }), + Effect.gen(function* () { + yield* apiClient.connections + .remove({ params: { owner: "org", integration: slug, name: connectionName } }) + .pipe(Effect.ignore); + yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), +); diff --git a/e2e/scenarios/openapi-update-spec.test.ts b/e2e/scenarios/openapi-update-spec.test.ts new file mode 100644 index 000000000..bf697e9ac --- /dev/null +++ b/e2e/scenarios/openapi-update-spec.test.ts @@ -0,0 +1,204 @@ +// Cross-target: updating an OpenAPI spec IN PLACE — the "our API changed, +// don't make me remove/re-add the integration" promise. A real 127.0.0.1 +// server serves the spec; after registration + connection the spec evolves +// (one operation removed, one added) and `openapi.updateSpec` re-fetches it. +// The tool catalog follows, the response reports the diff, and everything +// user-curated (connection, description, auth template) survives. +import { randomBytes, randomUUID } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const specV1 = JSON.stringify({ + openapi: "3.0.3", + info: { title: "Evolving API", version: "1.0.0" }, + paths: { + "/ping": { + get: { + operationId: "ping", + summary: "Return a pong", + responses: { "200": { description: "pong" } }, + }, + }, + "/legacy": { + get: { + operationId: "legacyOp", + summary: "Soon to be removed", + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); + +const specV2 = JSON.stringify({ + openapi: "3.0.3", + info: { title: "Evolving API", version: "2.0.0" }, + paths: { + "/ping": { + get: { + operationId: "ping", + summary: "Return a pong", + responses: { "200": { description: "pong" } }, + }, + }, + "/widgets": { + get: { + operationId: "listWidgets", + summary: "List widgets", + responses: { "200": { description: "widgets" } }, + }, + }, + }, +}); + +/** A real 127.0.0.1 server whose served spec can be swapped mid-scenario. */ +const serveMutableSpec = (initial: string) => + Effect.acquireRelease( + Effect.callback<{ + readonly url: string; + readonly setBody: (body: string) => void; + readonly close: () => void; + }>((resume) => { + let body = initial; + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(body); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}/spec.json`, + setBody: (next: string) => { + body = next; + }, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +scenario( + "OpenAPI · updating the spec rebuilds tools without re-adding the integration", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client } = yield* Api; + const identity = yield* target.newIdentity(); + const apiClient = yield* client(api, identity); + + const slug = `update-spec-${randomBytes(4).toString("hex")}`; + const specServer = yield* serveMutableSpec(specV1); + + yield* Effect.ensuring( + Effect.gen(function* () { + const added = yield* apiClient.openapi.addSpec({ + payload: { + spec: { kind: "url", url: specServer.url }, + slug, + baseUrl: "http://127.0.0.1:59999", // tools are never invoked here + description: "Curated description that must survive the update.", + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, + }, + ], + }, + }); + expect(added.toolCount, "v1 spec has two operations").toBe(2); + + const providers = yield* apiClient.providers.list(); + yield* apiClient.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("apiKey"), + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, + }, + }); + const toolNames = (filter: { integration: string }) => + Effect.map( + apiClient.tools.list({ + query: { integration: IntegrationSlug.make(filter.integration) }, + }), + (tools) => tools.map((tool) => tool.name).sort(), + ); + expect(yield* toolNames({ integration: slug }), "v1 tools are live").toEqual([ + "legacy.legacyOp", + "ping.getOperation", + ]); + + // The upstream API ships v2: legacyOp is gone, listWidgets appears. + specServer.setBody(specV2); + const updated = yield* apiClient.openapi.updateSpec({ + params: { slug }, + payload: {}, + }); + + expect(updated.addedTools, "the diff names the new tool").toEqual([ + "widgets.listWidgets", + ]); + expect(updated.removedTools, "the diff names the removed tool").toEqual([ + "legacy.legacyOp", + ]); + + // The connection's catalog follows the new spec — same connection, + // no re-add. + expect(yield* toolNames({ integration: slug }), "v2 tools are live").toEqual([ + "ping.getOperation", + "widgets.listWidgets", + ]); + const connections = yield* apiClient.connections.list({ + query: { integration: IntegrationSlug.make(slug) }, + }); + expect( + connections.map((connection) => String(connection.name)), + "the connection survived", + ).toEqual(["main"]); + const integration = yield* apiClient.integrations.get({ + params: { slug: IntegrationSlug.make(slug) }, + }); + expect(integration.description, "the curated description survived").toBe( + "Curated description that must survive the update.", + ); + }), + Effect.gen(function* () { + yield* apiClient.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: ConnectionName.make("main"), + }, + }) + .pipe(Effect.ignore); + yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/e2e/scenarios/tool-descriptions.test.ts b/e2e/scenarios/tool-descriptions.test.ts new file mode 100644 index 000000000..23bbcb8cc --- /dev/null +++ b/e2e/scenarios/tool-descriptions.test.ts @@ -0,0 +1,545 @@ +// Cross-target: the agent-visible description surface, captured as a +// reviewable artifact. Registers description-rich OpenAPI and GraphQL +// fixtures (every description channel a spec author can use: operation +// summary/description, parameter descriptions, body property descriptions, +// response descriptions, GraphQL field/arg/type docstrings), then dumps what +// an agent actually sees — tools.list entries and the tools.schema view +// (the same compiled TypeScript previews `tools.describe.tool()` returns in +// the sandbox) — into `descriptions.md` / `descriptions.json` in the run dir. +// +// The artifact is the point: change anything in the spec→tool description +// pipeline, run this scenario, and read (or diff) one file instead of +// spinning up an app and clicking around. Fixture slugs are randomized for +// catalog isolation but normalized back out of the artifact so two runs of +// the same code produce identical files. +import { randomBytes, randomUUID } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { buildSchema, introspectionFromSchema } from "graphql"; +import { composePluginApi } from "@executor-js/api/server"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, RunDir, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin(), graphqlHttpPlugin()] as const); + +// --------------------------------------------------------------------------- +// Fixtures — every description channel populated, so a drop anywhere in the +// pipeline is visible as "the fixture says X, the artifact doesn't". +// --------------------------------------------------------------------------- + +const ordersOpenApiSpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { + title: "Orders API", + version: "1.0.0", + description: "A fixture API exercising every OpenAPI description channel.", + }, + servers: [{ url: baseUrl }], + paths: { + "/orders/{orderId}": { + get: { + operationId: "getOrder", + summary: "Fetch a single order", + description: + "Fetch one order by id, including line items and the current fulfillment status.", + parameters: [ + { + name: "orderId", + in: "path", + required: true, + description: "Unique order identifier (ULID).", + schema: { type: "string" }, + }, + { + name: "include", + in: "query", + description: "Related records to embed in the response.", + schema: { type: "string", enum: ["items", "customer", "shipments"] }, + }, + ], + responses: { + "200": { + description: "The order, with any requested embeds.", + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "string", description: "Unique order identifier (ULID)." }, + status: { + type: "string", + description: "Current fulfillment status.", + enum: ["pending", "shipped", "delivered"], + }, + total: { + type: "number", + description: "Order total in minor currency units (cents).", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/orders": { + post: { + operationId: "createOrder", + summary: "Create an order", + // No `description` on purpose: the tool description must fall back + // to the summary, and the artifact shows which one won. + requestBody: { + required: true, + description: "The order to create.", + content: { + "application/json": { + schema: { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + description: "Line items to order.", + items: { + type: "object", + properties: { + sku: { type: "string", description: "Product SKU." }, + quantity: { + type: "integer", + description: "How many units of this SKU.", + }, + }, + }, + }, + note: { + type: "string", + description: "Free-form note shown to the warehouse packer.", + }, + }, + }, + }, + }, + }, + responses: { "201": { description: "The created order." } }, + }, + }, + }, + }); + +const ordersGraphqlSdl = /* GraphQL */ ` + """ + A customer order. + """ + type Order { + """ + Unique order identifier (ULID). + """ + id: ID! + + """ + Current fulfillment status. + """ + status: String! + } + + type Query { + """ + Fetch one order by id, including the current fulfillment status. + """ + order( + """ + Unique order identifier (ULID). + """ + id: ID! + ): Order + } + + type Mutation { + """ + Cancel an order that has not shipped yet. + """ + cancelOrder( + """ + Unique order identifier (ULID). + """ + id: ID! + """ + Human-readable reason, shown to the customer. + """ + reason: String + ): Order + } +`; + +const ordersIntrospectionJson = (): string => + JSON.stringify({ data: introspectionFromSchema(buildSchema(ordersGraphqlSdl)) }); + +// --------------------------------------------------------------------------- +// Artifact rendering +// --------------------------------------------------------------------------- + +interface ToolSnapshot { + readonly name: string; + readonly address: string; + readonly listDescription: string; + readonly schemaDescription?: string; + readonly inputTypeScript?: string; + readonly outputTypeScript?: string; + readonly typeScriptDefinitions?: Record; + readonly inputSchema?: unknown; +} + +const codeBlock = (lang: string, body: string): string => `\`\`\`${lang}\n${body}\n\`\`\``; + +const renderTool = (tool: ToolSnapshot): string => { + const lines: string[] = [`### \`${tool.name}\``, "", `> ${tool.listDescription || "(empty)"}`]; + if (tool.schemaDescription && tool.schemaDescription !== tool.listDescription) { + lines.push("", `tools.schema description differs:`, "", `> ${tool.schemaDescription}`); + } + if (tool.inputTypeScript) { + lines.push("", "**Input**", "", codeBlock("ts", tool.inputTypeScript)); + } + if (tool.outputTypeScript) { + lines.push("", "**Output**", "", codeBlock("ts", tool.outputTypeScript)); + } + const definitions = Object.entries(tool.typeScriptDefinitions ?? {}); + if (definitions.length > 0) { + lines.push("", "**Definitions**", ""); + for (const [name, body] of definitions) { + lines.push(codeBlock("ts", `type ${name} = ${body}`)); + } + } + if (tool.inputSchema !== undefined) { + lines.push( + "", + "
Raw inputSchema", + "", + codeBlock("json", JSON.stringify(tool.inputSchema, null, 2)), + "", + "
", + ); + } + return lines.join("\n"); +}; + +scenario( + "Tools · agent-visible descriptions snapshot", + {}, + Effect.gen(function* () { + const target = yield* Target; + const { client } = yield* Api; + const mcp = yield* Mcp; + const runDir = yield* RunDir; + const identity = yield* target.newIdentity(); + const apiClient = yield* client(api, identity); + + // Unique slugs per run keep repeated/parallel runs out of each other's + // catalogs (selfhost shares the bootstrap-admin identity). + const nonce = randomBytes(4).toString("hex"); + const openapiSlug = `descr-openapi-${nonce}`; + const graphqlSlug = `descr-graphql-${nonce}`; + const specBaseUrl = "http://127.0.0.1:59999"; // never contacted + + const apiKeyTemplate = [ + { + slug: "apiKey", + type: "apiKey", + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, + }, + ] as const; + + const connect = (slug: string) => + Effect.gen(function* () { + const providers = yield* apiClient.providers.list(); + yield* apiClient.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("apiKey"), + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, + }, + }); + }); + + const cleanup = (slug: string) => + Effect.gen(function* () { + yield* apiClient.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: ConnectionName.make("main"), + }, + }) + .pipe(Effect.ignore); + yield* apiClient.integrations + .remove({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore); + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + const added = yield* apiClient.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: ordersOpenApiSpec(specBaseUrl) }, + slug: openapiSlug, + baseUrl: specBaseUrl, + authenticationTemplate: apiKeyTemplate, + }, + }); + expect(added.toolCount, "the OpenAPI fixture's operations became tools").toBe(2); + + // Description set AT ADD (the add form's field) — no PATCH needed. + yield* apiClient.graphql.addIntegration({ + payload: { + endpoint: `${specBaseUrl}/graphql`, + slug: graphqlSlug, + description: "Order management over GraphQL.", + introspectionJson: ordersIntrospectionJson(), + authenticationTemplate: apiKeyTemplate, + }, + }); + + yield* connect(openapiSlug); + yield* connect(graphqlSlug); + + // Add-time prefill: with neither passed, the spec's title becomes the + // display name and its `info.description` the description. + const openapiIntegration = yield* apiClient.integrations.get({ + params: { slug: IntegrationSlug.make(openapiSlug) }, + }); + expect(openapiIntegration.name, "the spec title prefills the name").toBe("Orders API"); + expect( + openapiIntegration.description, + "the spec's info.description prefills the description", + ).toBe("A fixture API exercising every OpenAPI description channel."); + + // Post-add curation the way the console's edit sheets do: a + // connection-level description on the OpenAPI connection (its prefix + // line shows it; the GraphQL connection has none, so its line falls + // back to the integration description set at add). + yield* apiClient.connections.update({ + params: { + owner: "org", + integration: IntegrationSlug.make(openapiSlug), + name: ConnectionName.make("main"), + }, + payload: { description: "Staging orders — safe to create test orders." }, + }); + + // The agent-visible surface: catalog entry + schema view (the same + // data `tools.search()` / `tools.describe.tool()` serve the sandbox). + const snapshotFor = (slug: string) => + Effect.gen(function* () { + const tools = yield* apiClient.tools.list({ + query: { integration: IntegrationSlug.make(slug) }, + }); + return yield* Effect.forEach( + [...tools].sort((a, b) => a.name.localeCompare(b.name)), + (tool) => + Effect.gen(function* () { + const schema = yield* apiClient.tools.schema({ + query: { address: tool.address }, + }); + return { + name: tool.name, + address: String(tool.address), + listDescription: tool.description, + schemaDescription: schema.description, + inputTypeScript: schema.inputTypeScript, + outputTypeScript: schema.outputTypeScript, + typeScriptDefinitions: schema.typeScriptDefinitions, + inputSchema: schema.inputSchema, + } satisfies ToolSnapshot; + }), + ); + }); + + const openapiTools = yield* snapshotFor(openapiSlug); + const graphqlTools = yield* snapshotFor(graphqlSlug); + + // The execute tool's description over the real MCP surface — the + // connection-prefix inventory an MCP client (and its model) reads. + // Only this run's lines: the shared selfhost admin may have other + // connections in the inventory. + const readInventory = () => + Effect.map(mcp.session(identity).describeTools(), (mcpTools) => + (mcpTools.find((tool) => tool.name === "execute")?.description ?? "") + .split("## Available connection prefixes")[1] + ?.split("\n") + .filter( + (line) => + line.startsWith("- ") && + (line.includes(openapiSlug) || line.includes(graphqlSlug)), + ) + .join("\n"), + ); + const inventory = yield* readInventory(); + + // Normalize the per-run randomness so artifacts diff cleanly across + // runs: slugs become stable tokens, the owner segment a placeholder. + const owner = (yield* apiClient.tools.list({ query: {} })) + .map((tool) => String(tool.owner)) + .find((value) => value.length > 0); + const normalize = (text: string): string => { + let out = text + .replaceAll(openapiSlug, "openapi-fixture") + .replaceAll(graphqlSlug, "graphql-fixture"); + if (owner) out = out.replaceAll(owner, ""); + return out; + }; + + const sections = [ + ["OpenAPI fixture (`Orders API`)", openapiTools], + ["GraphQL fixture (orders schema)", graphqlTools], + ] as const; + const markdown = normalize( + [ + `# Agent-visible tool descriptions — ${target.name}`, + "", + "What an agent sees for tools derived from description-rich fixtures:", + "the `tools.list` catalog entry (quoted) and the `tools.schema` view —", + "the same compiled TypeScript previews `tools.describe.tool()` returns", + "inside the sandbox. Anything written in the fixture sources (bottom)", + "but absent here was dropped by the spec→tool pipeline.", + "", + ...sections.flatMap(([title, tools]) => [ + `## ${title}`, + "", + tools.map(renderTool).join("\n\n"), + "", + ]), + "## Execute-tool inventory (over MCP)", + "", + "The connection-prefix lines from the `execute` tool's description,", + "as an MCP client reads them. Connection descriptions ride their", + "prefix; a connection without one falls back to its integration's.", + "", + codeBlock("md", inventory ?? "(no inventory section found)"), + "", + "## Fixture sources", + "", + "
OpenAPI spec", + "", + codeBlock("json", JSON.stringify(JSON.parse(ordersOpenApiSpec(specBaseUrl)), null, 2)), + "", + "
", + "", + "
GraphQL SDL", + "", + codeBlock("graphql", ordersGraphqlSdl.trim()), + "", + "
", + "", + ].join("\n"), + ); + writeFileSync(join(runDir, "descriptions.md"), markdown); + writeFileSync( + join(runDir, "descriptions.json"), + normalize( + JSON.stringify( + { + target: target.name, + openapi: openapiTools, + graphql: graphqlTools, + executeInventory: inventory ?? null, + }, + null, + 2, + ), + ), + ); + + // Lock the channels that flow today; the artifact is the review + // surface for the ones that don't (yet). + const byName = (tools: readonly ToolSnapshot[], name: string) => + tools.find((tool) => tool.name === name); + + const getOrder = byName(openapiTools, "orders.getOrder"); + expect(getOrder?.listDescription, "operation description reaches the tool").toBe( + "Fetch one order by id, including line items and the current fulfillment status.", + ); + const createOrder = byName(openapiTools, "orders.createOrder"); + expect(createOrder?.listDescription, "summary is the fallback description").toBe( + "Create an order", + ); + expect(getOrder?.inputTypeScript, "input shape is compiled to TypeScript").toContain( + "orderId", + ); + + const orderQuery = byName(graphqlTools, "query.order"); + expect(orderQuery?.listDescription, "GraphQL field docstring reaches the tool").toBe( + "Fetch one order by id, including the current fulfillment status.", + ); + const cancelOrder = byName(graphqlTools, "mutation.cancelOrder"); + expect(cancelOrder?.inputTypeScript, "GraphQL args are compiled to TypeScript").toContain( + "reason", + ); + + // The curated descriptions reach the model: the connection's own + // description rides its prefix line; the connection without one falls + // back to its integration's description. + expect(inventory, "connection description reaches the MCP inventory").toContain( + `- \`${openapiSlug}.org.main\` — Staging orders — safe to create test orders.`, + ); + expect(inventory, "integration description is the fallback").toContain( + `- \`${graphqlSlug}.org.main\` — Order management over GraphQL.`, + ); + + // EDIT PROPAGATION — the loop the edit sheets exist for: an agent has + // already read the inventory above; the user now edits both + // descriptions (the exact PATCHes the sheets make); a NEW agent + // session must see the new text. (Within one session the execute + // description is computed at session build and stays as-is — the + // re-read below is a fresh session, which is also what a reconnecting + // client gets.) + yield* apiClient.connections.update({ + params: { + owner: "org", + integration: IntegrationSlug.make(openapiSlug), + name: ConnectionName.make("main"), + }, + payload: { description: "EDITED: production orders — do not create test data." }, + }); + yield* apiClient.integrations.update({ + params: { slug: IntegrationSlug.make(graphqlSlug) }, + payload: { description: "EDITED: order admin over GraphQL." }, + }); + + const inventoryAfterEdit = yield* readInventory(); + expect( + inventoryAfterEdit, + "an edited connection description reaches a fresh agent session", + ).toContain( + `- \`${openapiSlug}.org.main\` — EDITED: production orders — do not create test data.`, + ); + expect( + inventoryAfterEdit, + "an edited integration description reaches a fresh agent session", + ).toContain(`- \`${graphqlSlug}.org.main\` — EDITED: order admin over GraphQL.`); + expect(inventoryAfterEdit, "the pre-edit connection text is gone").not.toContain( + "Staging orders", + ); + }), + Effect.gen(function* () { + yield* cleanup(openapiSlug); + yield* cleanup(graphqlSlug); + }), + ); + }), +); diff --git a/e2e/src/surfaces/mcp.ts b/e2e/src/surfaces/mcp.ts index de9a0c74c..0e47555bb 100644 --- a/e2e/src/surfaces/mcp.ts +++ b/e2e/src/surfaces/mcp.ts @@ -95,6 +95,11 @@ export interface McpCallResult { readonly ok: boolean; } +export interface McpToolDef { + readonly name: string; + readonly description: string; +} + /** How a connection surfaces a paused (approval-gated) execution. `browser` is * what the browser-approval scenarios drive: the pause yields an `approvalUrl` * for a human to open instead of letting the model resume inline. */ @@ -133,6 +138,9 @@ export const parseBrowserApproval = (result: McpCallResult): McpBrowserApproval export interface McpSession { readonly listTools: () => Effect.Effect>; + /** Full advertised tool definitions — for asserting on the description text + * an MCP client actually reads (e.g. the execute tool's inventory). */ + readonly describeTools: () => Effect.Effect>; readonly call: (name: string, args?: Record) => Effect.Effect; /** Find the paused executionId in `text` and resume it with approval. */ readonly approvePaused: ( @@ -298,6 +306,16 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( return defs.map((tool: { name: string }) => tool.name); }); + const describeTools = () => + Effect.promise(async (): Promise> => { + const defs = await (await runtime()).listTools(serverName, callOptions); + connected = true; + return defs.map((tool: { name: string; description?: string }) => ({ + name: tool.name, + description: tool.description ?? "", + })); + }); + const call = (name: string, args: Record = {}) => Effect.promise(async (): Promise => { if (!connected) { @@ -311,6 +329,7 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( return { listTools, + describeTools, call, approvePaused: (text, content = {}) => Effect.suspend(() => { diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index a2e5c6852..b94a52f92 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -295,7 +295,7 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) {
{value.map((integration: Integration) => { const slug = String(integration.slug); - const name = integration.description || slug; + const name = integration.name || slug; const detailPath = `/integrations/${slug}`; const active = props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index c62ac8e39..0d44985c2 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -48,6 +48,7 @@ const ConnectionResponse = Schema.Struct({ provider: ProviderKey, address: ConnectionAddress, identityLabel: Schema.NullOr(Schema.String), + description: Schema.NullOr(Schema.String), expiresAt: Schema.NullOr(Schema.Number), // The OAuth app that minted this connection (its `oauth_client` slug), or null // for static credentials. Lets the UI map a connection back to its app. Just a @@ -80,8 +81,15 @@ const CommonCreateFields = { integration: IntegrationSlug, template: AuthTemplateSlug, identityLabel: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), } as const; +// User-curated metadata edits. Absent field = unchanged; null = cleared. +const UpdateConnectionPayload = Schema.Struct({ + description: Schema.optional(Schema.NullOr(Schema.String)), + identityLabel: Schema.optional(Schema.NullOr(Schema.String)), +}); + const CreateConnectionPayload = Schema.Struct({ ...CommonCreateFields, value: Schema.optional(Schema.String), @@ -157,6 +165,14 @@ export const ConnectionsApi = HttpApiGroup.make("connections") error: [InternalError, ConnectionNotFound], }), ) + .add( + HttpApiEndpoint.patch("update", "/connections/:owner/:integration/:name", { + params: ConnectionParams, + payload: UpdateConnectionPayload, + success: ConnectionResponse, + error: [InternalError, ConnectionNotFound], + }), + ) .add( HttpApiEndpoint.delete("remove", "/connections/:owner/:integration/:name", { params: ConnectionParams, diff --git a/packages/core/api/src/handlers/connections.ts b/packages/core/api/src/handlers/connections.ts index 86295aa3f..d3ffb5745 100644 --- a/packages/core/api/src/handlers/connections.ts +++ b/packages/core/api/src/handlers/connections.ts @@ -21,6 +21,7 @@ const toResponse = (c: Connection) => ({ provider: c.provider, address: c.address, identityLabel: c.identityLabel ?? null, + description: c.description ?? null, expiresAt: c.expiresAt ?? null, oauthClient: c.oauthClient ?? null, oauthClientOwner: c.oauthClientOwner ?? null, @@ -83,6 +84,27 @@ export const ConnectionsHandlers = HttpApiBuilder.group(ExecutorApi, "connection }), ), ) + .handle("update", ({ params: path, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const updated = yield* executor.connections.update( + { + owner: path.owner, + integration: path.integration, + name: path.name, + }, + { + ...(payload.description !== undefined ? { description: payload.description } : {}), + ...(payload.identityLabel !== undefined + ? { identityLabel: payload.identityLabel } + : {}), + }, + ); + return toResponse(updated); + }), + ), + ) .handle("remove", ({ params: path }) => capture( Effect.gen(function* () { diff --git a/packages/core/api/src/handlers/integrations.ts b/packages/core/api/src/handlers/integrations.ts index fb4d22c9d..25ca35685 100644 --- a/packages/core/api/src/handlers/integrations.ts +++ b/packages/core/api/src/handlers/integrations.ts @@ -8,6 +8,7 @@ import { capture } from "@executor-js/api"; const toResponse = (i: Integration) => ({ slug: i.slug, + name: i.name, description: i.description, kind: i.kind, canRemove: i.canRemove, @@ -44,7 +45,8 @@ export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrati Effect.gen(function* () { const executor = yield* ExecutorService; yield* executor.integrations.update(path.slug, { - description: payload.description, + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.description !== undefined ? { description: payload.description } : {}), }); const integration = yield* executor.integrations.get(path.slug); if (integration === null) { diff --git a/packages/core/api/src/integrations/api.ts b/packages/core/api/src/integrations/api.ts index cfc997e05..c92dc1db3 100644 --- a/packages/core/api/src/integrations/api.ts +++ b/packages/core/api/src/integrations/api.ts @@ -65,6 +65,8 @@ const AuthMethodDescriptorSchema = Schema.Struct({ /** Public projection of an integration — mirrors the SDK's `Integration`. */ const IntegrationResponse = Schema.Struct({ slug: IntegrationSlug, + /** Display name. */ + name: Schema.String, description: Schema.String, /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */ kind: Schema.String, @@ -78,6 +80,7 @@ const IntegrationResponse = Schema.Struct({ }); const UpdateIntegrationPayload = Schema.Struct({ + name: Schema.optional(Schema.String), description: Schema.optional(Schema.String), }); diff --git a/packages/core/execution/src/description.test.ts b/packages/core/execution/src/description.test.ts index 2cc38c0e7..9d96efcc5 100644 --- a/packages/core/execution/src/description.test.ts +++ b/packages/core/execution/src/description.test.ts @@ -61,7 +61,8 @@ const slackPlugin = definePlugin(() => ({ seed: () => ctx.core.integrations.register({ slug: SLACK, - description: "Slack Workspace", + name: "Slack", + description: "Send and read workspace messages.", config: {}, }), }), @@ -98,7 +99,7 @@ describe("buildExecuteDescription", () => { expect(description).toContain("- `github.org.prod`"); expect(description).toContain("- `github.user.personal`"); expect(description).not.toContain("## Available namespaces"); - expect(description).not.toContain("Slack Workspace"); + expect(description).not.toContain("workspace messages"); expect(description).not.toContain("`github-plugin`"); expect(description).not.toContain("`slack-plugin`"); expect(description).not.toContain("- `github`"); @@ -111,6 +112,60 @@ describe("buildExecuteDescription", () => { }), ); + it.effect("annotates prefixes with connection or integration descriptions", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), + ); + yield* executor["slack-plugin"].seed(); + yield* executor["github-plugin"].seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("prod"), + integration: GITHUB, + template: TEMPLATE, + value: "org-token", + description: "Production org — issues and PRs only.", + }); + yield* executor.connections.create({ + owner: "user", + name: ConnectionName.make("personal"), + integration: GITHUB, + template: TEMPLATE, + value: "user-token", + }); + + const description = yield* buildExecuteDescription(executor); + + // The curated connection description rides its prefix line. + expect(description).toContain("- `github.org.prod` — Production org — issues and PRs only."); + // No connection description and the integration description ("GitHub") + // just restates the slug — the line stays bare. + expect(description).toContain("- `github.user.personal`"); + expect(description).not.toContain("- `github.user.personal` —"); + }), + ); + + it.effect("falls back to a meaningful integration description", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), + ); + yield* executor["slack-plugin"].seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: SLACK, + template: TEMPLATE, + value: "slack-token", + }); + + const description = yield* buildExecuteDescription(executor); + + expect(description).toContain("- `slack.org.main` — Send and read workspace messages."); + }), + ); + it.effect("omits the Available connection prefixes section when no connections exist", () => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const })); diff --git a/packages/core/execution/src/description.ts b/packages/core/execution/src/description.ts index 413804528..e5e227cfc 100644 --- a/packages/core/execution/src/description.ts +++ b/packages/core/execution/src/description.ts @@ -1,5 +1,5 @@ import { Effect } from "effect"; -import type { Connection, Executor } from "@executor-js/sdk/core"; +import type { Connection, Integration, Executor } from "@executor-js/sdk/core"; /** * Builds a tool description dynamically. @@ -19,9 +19,14 @@ export const buildExecuteDescription = (executor: Executor): Effect.Effect - formatDescription(connections.map((connection) => connectionPath(connection))), + formatDescription(connections.map((connection) => connectionEntry(connection, integrations))), ).pipe( Effect.withSpan("schema.compile.description", { attributes: { "executor.connection_count": connections.length }, @@ -53,7 +58,44 @@ const connectionPath = (connection: Connection): string => { return address.startsWith("tools.") ? address.slice("tools.".length) : address; }; -const formatDescription = (connectionPrefixes: readonly string[]): string => { +/** One inventory line: the callable prefix plus the best available context. + * Connection description wins (the user wrote it about THIS credential); + * otherwise the integration description, unless it is just the slug again. */ +interface ConnectionInventoryEntry { + readonly prefix: string; + readonly description?: string; +} + +const inventoryNote = ( + text: string | null | undefined, + identityEchoes: readonly string[], +): string | undefined => { + const firstLine = (text ?? "").split("\n", 1)[0]!.trim(); + if (firstLine.length === 0) return undefined; + // A description that just restates the slug or display name carries no + // information beyond identity — drop it from the inventory line. + if (identityEchoes.some((echo) => firstLine.toLowerCase() === echo.toLowerCase())) { + return undefined; + } + return firstLine.length > 140 ? `${firstLine.slice(0, 139)}…` : firstLine; +}; + +const connectionEntry = ( + connection: Connection, + integrations: readonly Integration[], +): ConnectionInventoryEntry => { + const slug = String(connection.integration); + const integration = integrations.find((candidate) => String(candidate.slug) === slug); + const identityEchoes = [slug, ...(integration ? [integration.name] : [])]; + return { + prefix: connectionPath(connection), + description: + inventoryNote(connection.description, identityEchoes) ?? + inventoryNote(integration?.description, identityEchoes), + }; +}; + +const formatDescription = (connectionEntries: readonly ConnectionInventoryEntry[]): string => { const lines: string[] = [ "Execute TypeScript in a sandboxed runtime with access to configured API tools.", "", @@ -84,17 +126,23 @@ const formatDescription = (connectionPrefixes: readonly string[]): string => { "- TypeScript type syntax (`: T`, `as T`, generics, interfaces, type aliases) is stripped before execution — feel free to write idiomatic TypeScript using the shapes from `tools.describe.tool()`. Decorators and `enum` are not supported.", ]; - if (connectionPrefixes.length > 0) { + if (connectionEntries.length > 0) { lines.push(""); lines.push("## Available connection prefixes"); lines.push(""); lines.push("These are paths under `tools.`; append the final tool segment."); - const sorted = [...connectionPrefixes].sort((a, b) => a.localeCompare(b)).slice(0, 50); - for (const prefix of sorted) { - lines.push(`- \`${prefix}\``); + const sorted = [...connectionEntries] + .sort((a, b) => a.prefix.localeCompare(b.prefix)) + .slice(0, 50); + for (const entry of sorted) { + lines.push( + entry.description + ? `- \`${entry.prefix}\` — ${entry.description}` + : `- \`${entry.prefix}\``, + ); } - if (connectionPrefixes.length > sorted.length) { - lines.push(`- ... ${connectionPrefixes.length - sorted.length} more`); + if (connectionEntries.length > sorted.length) { + lines.push(`- ... ${connectionEntries.length - sorted.length} more`); } } diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts index 9b217559d..cf57304a5 100644 --- a/packages/core/execution/src/promise.ts +++ b/packages/core/execution/src/promise.ts @@ -90,6 +90,10 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => { fromPromise(() => pe.connections.list(filter)), get: (ref: Parameters[0]) => fromPromise(() => pe.connections.get(ref)), + update: ( + ref: Parameters[0], + input: Parameters[1], + ) => fromPromise(() => pe.connections.update(ref, input)), remove: (ref: Parameters[0]) => fromPromise(() => pe.connections.remove(ref)), refresh: (ref: Parameters[0]) => diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 4e3448095..d4092b2f8 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -111,7 +111,7 @@ const BUILTIN_TOOL_DESCRIPTIONS: ReadonlyMap = new Map< "{ items: ExecutorSourceListItem[]; total: number; hasMore: boolean; nextOffset: number | null; }", typeScriptDefinitions: { ExecutorSourceListItem: - "{ id: string; name: string; kind: string; canRemove?: boolean; canRefresh?: boolean; toolCount: number; }", + "{ id: string; name: string; description?: string; kind: string; canRemove?: boolean; canRefresh?: boolean; toolCount: number; }", }, }, ], @@ -320,6 +320,7 @@ export type ToolDiscoveryResult = { export type ExecutorSourceListItem = { readonly id: string; readonly name: string; + readonly description?: string; readonly kind: string; readonly canRemove?: boolean; readonly canRefresh?: boolean; @@ -682,6 +683,14 @@ export const listExecutorSources = Effect.fn("executor.sources.list")(function* ({ id: String(integration.slug), name: String(integration.slug), + // The integration's catalog description — user-editable context the + // agent can use to pick a source. Omitted when it just repeats the + // slug or display name (no information beyond identity). + ...(integration.description && + integration.description.toLowerCase() !== String(integration.slug).toLowerCase() && + integration.description.toLowerCase() !== integration.name.toLowerCase() + ? { description: integration.description } + : {}), kind: integration.kind, canRemove: integration.canRemove, canRefresh: integration.canRefresh, diff --git a/packages/core/fumadb/package.json b/packages/core/fumadb/package.json index 0b0db7d6e..ccd101adf 100644 --- a/packages/core/fumadb/package.json +++ b/packages/core/fumadb/package.json @@ -88,11 +88,12 @@ "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src", "typecheck": "tsgo --noEmit", "typecheck:slow": "tsc --noEmit", - "test": "vitest run src/**/*.test.ts test/generate.test.ts test/uuid.test.ts --passWithNoTests", + "test": "vitest run src test/generate.test.ts test/uuid.test.ts --passWithNoTests", "test:integration": "vitest run test/**/*.test.ts --passWithNoTests" }, "devDependencies": { "@effect/vitest": "catalog:", + "@libsql/client": "catalog:", "@types/better-sqlite3": "^7.6.13", "@types/node": "catalog:", "@types/pg": "^8.20.0", diff --git a/packages/core/fumadb/src/adapters/drizzle/runtime-ensure.test.ts b/packages/core/fumadb/src/adapters/drizzle/runtime-ensure.test.ts new file mode 100644 index 000000000..e49552d7e --- /dev/null +++ b/packages/core/fumadb/src/adapters/drizzle/runtime-ensure.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, test } from "@effect/vitest"; +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; + +import { + createDrizzleRuntimeSchemaSqlFromTables, + ensureDrizzleRuntimeSchemaFromTables, +} from "./index"; +import { column, idColumn, table } from "../../schema"; + +const NS = "executor"; + +// An older database baseline: `integration` has only id + slug. +const v1Tables = { + integration: table("integration", { + id: idColumn("id", "varchar(255)"), + slug: column("slug", "varchar(255)"), + }), +}; + +// The running schema adds two nullable columns the old file predates — the +// shape of the integration-descriptions feature (name + config_revised_at). +const v2Tables = { + integration: table("integration", { + id: idColumn("id", "varchar(255)"), + slug: column("slug", "varchar(255)"), + name: column("name", "string").nullable(), + config_revised_at: column("config_revised_at", "bigint").nullable(), + }), +}; + +const columnNames = async (client: ReturnType): Promise => { + const info = await client.execute("PRAGMA table_info('integration')"); + return info.rows.map((row) => String(row["name"])); +}; + +// This is the boot bring-up that backs apps/local, apps/host-selfhost, and +// apps/host-cloudflare: `CREATE TABLE IF NOT EXISTS` never alters an existing +// table, so without column evolution a file from an earlier baseline would +// 500 on the first query against a new column. (Cloud Postgres gets the same +// columns from a generated drizzle migration instead.) +test("ensure evolves an existing table with the schema's new nullable columns", async () => { + const dir = mkdtempSync(join(tmpdir(), "fumadb-ensure-")); + const client = createClient({ url: `file:${join(dir, "test.db")}` }); + const db = drizzle({ client }); + + try { + // Stand up the old database baseline directly (CREATE TABLE only). + for (const statement of createDrizzleRuntimeSchemaSqlFromTables({ + tables: v1Tables, + namespace: NS, + version: "1.0.0", + provider: "sqlite", + })) { + await client.execute(statement); + } + expect(await columnNames(client)).not.toContain("name"); + + // Boot with the new schema → the missing columns are added in place. + await ensureDrizzleRuntimeSchemaFromTables(db, { + tables: v2Tables, + namespace: NS, + version: "1.0.0", + provider: "sqlite", + }); + const evolved = await columnNames(client); + expect(evolved).toContain("name"); + expect(evolved).toContain("config_revised_at"); + + // Idempotent: a second boot tolerates the now-duplicate columns. + await ensureDrizzleRuntimeSchemaFromTables(db, { + tables: v2Tables, + namespace: NS, + version: "1.0.0", + provider: "sqlite", + }); + expect(await columnNames(client)).toEqual(evolved); + } finally { + client.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/core/fumadb/src/adapters/drizzle/runtime.ts b/packages/core/fumadb/src/adapters/drizzle/runtime.ts index ad33f9d77..8c2d53621 100644 --- a/packages/core/fumadb/src/adapters/drizzle/runtime.ts +++ b/packages/core/fumadb/src/adapters/drizzle/runtime.ts @@ -330,6 +330,39 @@ export const createDrizzleRuntimeSchemaSql = ( createSettingsTableSql(options.namespace, options.schema.version, options.provider), ]; +/** `ALTER TABLE … ADD COLUMN` statements for every NULLABLE column, one per + * column. `CREATE TABLE IF NOT EXISTS` never touches an existing table, so a + * schema-evolution column would otherwise only exist in fresh databases. Each + * statement fails with a duplicate-column error when the column already + * exists — callers run them individually and swallow exactly that error, + * which keeps this idempotent without provider-specific catalog probes. + * Non-nullable additions can't be backfilled blindly and still need a real + * migration. */ +const addNullableColumnsSql = (options: DrizzleRuntimeSchemaOptions): readonly string[] => + Object.values(options.schema.tables).flatMap((table) => + Object.values(table.columns) + .filter((column) => column.isNullable && !(column instanceof IdColumn)) + .map((column) => + [ + "ALTER TABLE", + quoteIdent(table.names.sql), + "ADD COLUMN", + columnDefinitionSql(column, options.provider), + ].join(" "), + ), + ); + +const isDuplicateColumnError = (error: unknown): boolean => { + // Drivers wrap the SQL error (e.g. DrizzleQueryError → LibsqlError), so + // match anywhere down the cause chain. + for (let current = error, depth = 0; current != null && depth < 8; depth += 1) { + const message = current instanceof Error ? current.message : String(current); + if (/duplicate column|already exists/i.test(message)) return true; + current = current instanceof Error ? current.cause : null; + } + return false; +}; + export const createDrizzleRuntimeSchemaSqlFromTables = ( options: DrizzleRuntimeTablesOptions, ): readonly string[] => @@ -370,6 +403,17 @@ export const ensureDrizzleRuntimeSchema = async ( } else { await run(db); } + + // Evolve existing tables: add any nullable columns they predate. Outside + // the transaction — each statement individually either succeeds (column was + // missing) or fails with a duplicate-column error (column exists, ignored). + for (const statement of addNullableColumnsSql(options)) { + try { + await runStatement(db, statement); + } catch (error) { + if (!isDuplicateColumnError(error)) throw error; + } + } }; export const ensureDrizzleRuntimeSchemaFromTables = async ( diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index 35e35d72c..f69ff833b 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -115,6 +115,18 @@ export interface IntegrationAccountHandoff { readonly label?: string; } +/** Outcome of applying an edit-sheet section's staged change. `summary` is + * toasted on success; `ok: false` keeps the sheet open (the section renders + * its own error inline). */ +export type EditSheetApplyResult = + | { readonly ok: true; readonly summary: string | null } + | { readonly ok: false }; + +export interface EditSheetSectionProps { + readonly sourceId: string; + readonly onPendingChange?: (apply: (() => Promise) | null) => void; +} + export interface IntegrationPlugin { /** Unique key matching the SDK plugin id (e.g. "openapi"). */ readonly key: string; @@ -130,10 +142,19 @@ export interface IntegrationPlugin { readonly initialPreset?: string; readonly initialNamespace?: string; }>; - readonly edit: ComponentType<{ + /** Legacy full-page edit surface. No host renders this anymore — plugin + * configuration lives in the integration Edit sheet via `editSheet`. */ + readonly edit?: ComponentType<{ readonly sourceId: string; readonly onSave: () => void; }>; + /** Plugin-owned configuration rendered inside the integration's Edit sheet, + * below the shared metadata fields (e.g. the OpenAPI spec-update controls). + * The sheet has ONE Save: the section stages its pending change locally and + * reports it through `onPendingChange` — a thunk that applies the staged + * change. Save runs the metadata update, then the staged apply; a failed + * apply keeps the sheet open with the section showing its own error. */ + readonly editSheet?: ComponentType; readonly summary?: ComponentType<{ readonly sourceId: string; readonly variant?: "badge" | "panel"; diff --git a/packages/core/sdk/src/connection.ts b/packages/core/sdk/src/connection.ts index e5beb0106..83b3ad247 100644 --- a/packages/core/sdk/src/connection.ts +++ b/packages/core/sdk/src/connection.ts @@ -32,6 +32,11 @@ export interface Connection { readonly address: ConnectionAddress; /** Optional human label (which account). Not load-bearing. */ readonly identityLabel?: string | null; + /** User-curated description of what this connection is for. Agent-visible: + * surfaces next to the connection's prefix in the execute-tool inventory + * and in `connections.list`, so it is the place to give agents context a + * spec can't (e.g. "the staging CRM — reads only"). */ + readonly description?: string | null; /** Epoch ms when an OAuth access token expires; null/absent for static creds. */ readonly expiresAt?: number | null; /** The OAuth app (`oauth_client` slug) that minted this connection, when it @@ -87,4 +92,13 @@ export type CreateConnectionInput = { readonly integration: IntegrationSlug; readonly template: AuthTemplateSlug; readonly identityLabel?: string | null; + readonly description?: string | null; } & ConnectionValueInput; + +/** Edit a connection's user-curated metadata. Only the provided fields change; + * credentials and OAuth lifecycle are untouchable here (recreate or refresh + * instead). */ +export interface UpdateConnectionInput { + readonly description?: string | null; + readonly identityLabel?: string | null; +} diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 1e8ff2d60..75469e112 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -120,8 +120,23 @@ export const coreTables = defineTables({ { slug: keyColumn("slug"), plugin_id: textColumn("plugin_id"), - description: textColumn("description"), + // Display name. The pre-split field: `description` used to hold the + // name, so cloud backfills `name` from it (migration 0006) and other + // hosts fall back at read time (see rowToIntegration). Nullable because + // SQLite boot-ensure hosts cannot add a NOT NULL column to existing + // tables, so the column stays nullable even though it is always present + // in practice. + name: nullableTextColumn("name"), + // Actual prose description, now distinct from the name. Nullable: absent + // until a user/spec supplies one (cloud clears the old duplicated title + // to NULL in 0006). + description: nullableTextColumn("description"), config: nullableJsonColumn("config"), + // Epoch ms of the last tool-affecting config change (spec update, auth + // template edit). Compared against each connection's `tools_synced_at` + // so OTHER subjects' connections — whose tool rows the updater cannot + // write under the owner policy — lazily rebuild on their next read. + config_revised_at: nullableBigintColumn("config_revised_at"), can_remove: boolColumn("can_remove", true), can_refresh: boolColumn("can_refresh", false), created_at: dateColumn("created_at"), @@ -144,6 +159,12 @@ export const coreTables = defineTables({ provider: textColumn("provider"), item_ids: jsonColumn("item_ids"), identity_label: nullableTextColumn("identity_label"), + // User-curated, agent-visible "what is this connection for". Settable at + // create, editable after; never reset by OAuth re-mints. + description: nullableTextColumn("description"), + // Epoch ms of the last tool (re)production for this connection. Stale + // vs the integration's `config_revised_at` → re-produced on next read. + tools_synced_at: nullableBigintColumn("tools_synced_at"), oauth_client: nullableTextColumn("oauth_client"), // The OWNER of `oauth_client` (a Personal connection may be minted through // a shared Workspace app), set together with `oauth_client`; null for diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 921402031..522d15bfd 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -70,6 +70,7 @@ const ConnectionOutput = Schema.Struct({ provider: Schema.String, address: Schema.String, identityLabel: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), expiresAt: Schema.NullOr(Schema.Number), oauthClient: Schema.NullOr(Schema.String), oauthClientOwner: Schema.NullOr(OwnerSchema), @@ -303,6 +304,7 @@ const connectionToOutput = (connection: Connection) => ({ provider: String(connection.provider), address: String(connection.address), identityLabel: connection.identityLabel ?? null, + description: connection.description ?? null, expiresAt: connection.expiresAt ?? null, oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient), oauthClientOwner: connection.oauthClientOwner ?? null, diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 2413d5549..cfa55b58a 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -24,6 +24,7 @@ import type { ConnectionRef, CreateConnectionInput, ConnectionValueInput, + UpdateConnectionInput, } from "./connection"; import { coreSchema, @@ -246,7 +247,7 @@ export type Executor = { readonly get: (slug: IntegrationSlug) => Effect.Effect; readonly update: ( slug: IntegrationSlug, - patch: { readonly description?: string }, + patch: { readonly name?: string; readonly description?: string }, ) => Effect.Effect; readonly remove: ( slug: IntegrationSlug, @@ -271,6 +272,12 @@ export type Executor = { readonly owner?: Owner; }) => Effect.Effect; readonly get: (ref: ConnectionRef) => Effect.Effect; + /** Edit user-curated metadata (description, identityLabel). Credentials and + * OAuth lifecycle fields are not editable here. */ + readonly update: ( + ref: ConnectionRef, + input: UpdateConnectionInput, + ) => Effect.Effect; readonly remove: ( ref: ConnectionRef, ) => Effect.Effect; @@ -441,7 +448,11 @@ const rowToIntegration = ( displayUrl?: string, ): Integration => ({ slug: IntegrationSlug.make(row.slug), - description: row.description, + // Pre-split rows have no `name`; their description WAS the display name. + name: row.name ?? row.description ?? row.slug, + // `description` is now nullable (cleared where it only held a duplicated + // title); present it as "" so the public Integration type stays a string. + description: row.description ?? "", kind: row.plugin_id, canRemove: Boolean(row.can_remove), canRefresh: Boolean(row.can_refresh), @@ -469,6 +480,7 @@ const rowToConnection = (row: ConnectionRow): Connection => { provider: ProviderKey.make(row.provider), address: connectionAddress(owner, integration, name), identityLabel: row.identity_label ?? null, + description: row.description ?? null, expiresAt: row.expires_at == null ? null : Number(row.expires_at), oauthClient: row.oauth_client == null ? null : OAuthClientSlug.make(String(row.oauth_client)), oauthClientOwner: @@ -1270,6 +1282,7 @@ export const createExecutor = ({ slug: IntegrationSlug.make(source.id), + name: source.name, description: source.name, kind: source.kind, canRemove: source.canRemove ?? false, @@ -1705,6 +1718,7 @@ export const createExecutor = b("slug", "=", String(input.slug)), set: { plugin_id: pluginId, + name: input.name ?? existing.name ?? null, description: input.description, config, can_remove: input.canRemove ?? Boolean(existing.can_remove), @@ -1718,6 +1732,7 @@ export const createExecutor = => Effect.gen(function* () { - const set: Record = { updated_at: new Date() }; + const now = new Date(); + const set: Record = { updated_at: now }; + if (patch.name !== undefined) set.name = patch.name; if (patch.description !== undefined) set.description = patch.description; - if (patch.config !== undefined) set.config = patch.config; + if (patch.config !== undefined) { + set.config = patch.config; + // A config change can change the derived tools. The writer can only + // rebuild catalogs in its own partition (owner policy), so revise + // the integration: other subjects' connections compare this stamp + // against their `tools_synced_at` and lazily rebuild on next read. + set.config_revised_at = now.getTime(); + } yield* core.updateMany("integration", { where: (b: AnyCb) => b("slug", "=", String(slug)), set, @@ -1747,7 +1772,7 @@ export const createExecutor = => Effect.gen(function* () { const existing = yield* findIntegrationRow(slug); @@ -1814,6 +1839,18 @@ export const createExecutor = + b.and( + byOwner(owner)(b), + b("integration", "=", String(ref.integration)), + b("name", "=", String(ref.name)), + ), + set: { tools_synced_at: Date.now() }, + }); // Defense in depth (and cleanup for rows created before the create-time // guard, or emptied by an external edit): a credentialed non-OAuth @@ -1834,6 +1871,7 @@ export const createExecutor = => findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null))); + const connectionsUpdate = ( + ref: ConnectionRef, + input: UpdateConnectionInput, + ): Effect.Effect => + Effect.gen(function* () { + const row = yield* findConnectionRow(ref); + if (!row) { + return yield* new ConnectionNotFoundError({ + owner: ref.owner, + integration: ref.integration, + name: ref.name, + }); + } + const set: Record = { updated_at: new Date() }; + if (input.description !== undefined) set.description = input.description; + if (input.identityLabel !== undefined) set.identity_label = input.identityLabel; + yield* core.updateMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(ref.owner)(b), + b("integration", "=", String(ref.integration)), + b("name", "=", String(ref.name)), + ), + set, + }); + const updated = yield* findConnectionRow(ref); + return rowToConnection(updated ?? row); + }); + const connectionsRemove = ( ref: ConnectionRef, ): Effect.Effect => @@ -2320,8 +2398,50 @@ export const createExecutor = b.isNotNull("config_revised_at"), + }); + if (revised.length === 0) return; + const revisedAt = new Map( + revised.map((row) => [row.slug, Number(row.config_revised_at)] as const), + ); + const connections = yield* core.findMany("connection", { + where: (b: AnyCb) => b.or(...revised.map((row) => b("integration", "=", row.slug))), + }); + for (const connection of connections) { + const revisedTime = revisedAt.get(connection.integration); + if (revisedTime === undefined) continue; + const syncedAt = + connection.tools_synced_at == null ? 0 : Number(connection.tools_synced_at); + if (syncedAt >= revisedTime) continue; + const integrationRow = revised.find((row) => row.slug === connection.integration); + if (!integrationRow) continue; + yield* produceConnectionTools(integrationRow, { + owner: connection.owner as Owner, + integration: IntegrationSlug.make(connection.integration), + name: ConnectionName.make(connection.name), + }).pipe( + Effect.catch(() => Effect.succeed([] as readonly Tool[])), + Effect.withSpan("executor.tools.sync_stale", { + attributes: { + "executor.integration": connection.integration, + "executor.connection": connection.name, + }, + }), + ); + } + }); + const toolsList = (filter?: ToolListFilter): Effect.Effect => Effect.gen(function* () { + yield* syncStaleConnectionTools; // Projected: the list surface is metadata (address, description, // annotations) — loading every tool's input/output schema JSON made // an unbounded list scale with schema bytes, not tool count. @@ -3018,6 +3138,7 @@ export const createExecutor = connectionsCreate(input), list: (filter) => connectionsList(filter), get: (ref) => connectionsGet(ref), + update: (ref, input) => connectionsUpdate(ref, input), remove: (ref) => connectionsRemove(ref), refresh: (ref) => connectionsRefresh(ref), resolveValue: (ref) => resolveConnectionValueByRef(ref), @@ -3124,6 +3245,7 @@ export const createExecutor = ( * produced per-connection now). */ export interface RegisterIntegrationInput { readonly slug: IntegrationSlug; + /** Display name. Falls back to `description` then the slug when omitted + * (legacy callers registered with description-as-name). */ + readonly name?: string; readonly description: string; /** Opaque plugin config (auth templates, spec ref, mcp url, …). */ readonly config: IntegrationConfig; diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index d48041f82..a56e7952e 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -6,7 +6,12 @@ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/sp import type { StorageFailure } from "./fuma-runtime"; import type { PluginBlobStore } from "./blob"; -import type { Connection, ConnectionRef, CreateConnectionInput } from "./connection"; +import type { + Connection, + ConnectionRef, + CreateConnectionInput, + UpdateConnectionInput, +} from "./connection"; import type { AuthMethodDescriptor, Integration, @@ -111,7 +116,11 @@ export interface PluginCtx { readonly register: (input: RegisterIntegrationInput) => Effect.Effect; readonly update: ( slug: IntegrationSlug, - patch: { readonly description?: string; readonly config?: IntegrationConfig }, + patch: { + readonly name?: string; + readonly description?: string; + readonly config?: IntegrationConfig; + }, ) => Effect.Effect; readonly list: () => Effect.Effect; readonly get: ( @@ -151,6 +160,11 @@ export interface PluginCtx { readonly owner?: Owner; }) => Effect.Effect; readonly get: (ref: ConnectionRef) => Effect.Effect; + /** Edit user-curated metadata (description, identityLabel). */ + readonly update: ( + ref: ConnectionRef, + input: UpdateConnectionInput, + ) => Effect.Effect; readonly remove: ( ref: ConnectionRef, ) => Effect.Effect; diff --git a/packages/core/sdk/src/promise.ts b/packages/core/sdk/src/promise.ts index a486c8e4b..d48d10607 100644 --- a/packages/core/sdk/src/promise.ts +++ b/packages/core/sdk/src/promise.ts @@ -32,6 +32,7 @@ export type { Connection, ConnectionRef, CreateConnectionInput, + UpdateConnectionInput, ConnectionValueInput, } from "./connection"; // Credential providers are Effect-native (their `get`/`set` return `Effect`s), diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 30724bfbd..05a6f889d 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -42,6 +42,7 @@ export type { ConnectionRef, ConnectionValueInput, CreateConnectionInput, + UpdateConnectionInput, } from "./connection"; export type { CredentialProvider, ProviderEntry } from "./provider"; export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 74fbf4779..363038ef5 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -21,6 +21,7 @@ const AddIntegrationPayload = Schema.Struct({ endpoint: Schema.String, slug: Schema.optional(Schema.String), name: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), introspectionJson: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index 1e7a8304e..325924f0c 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -40,6 +40,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra endpoint: payload.endpoint, slug: payload.slug, name: payload.name, + description: payload.description, introspectionJson: payload.introspectionJson, headers: payload.headers, queryParams: payload.queryParams, diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index 423bd9b16..d45065dce 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -45,6 +45,7 @@ export default function AddGraphqlSource(props: { initialUrl?: string; }) { const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); + const [description, setDescription] = useState(""); const identity = useIntegrationIdentity({ fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", }); @@ -111,6 +112,7 @@ export default function AddGraphqlSource(props: { endpoint: trimmedEndpoint, slug, name: displayName, + ...(description.trim().length > 0 ? { description: description.trim() } : {}), ...(authenticationTemplate.length > 0 ? { authenticationTemplate: [...authenticationTemplate] } : {}), @@ -131,7 +133,13 @@ export default function AddGraphqlSource(props: {

Add GraphQL Source

- + void; -}) { - const slug = IntegrationSlug.make(props.sourceId); - const configResult = useAtomValue(graphqlIntegrationConfigAtom(slug)); - const config = AsyncResult.isSuccess(configResult) - ? Option.getOrNull(decodeGraphqlIntegrationConfigOption(configResult.value)) - : null; - - if (!AsyncResult.isSuccess(configResult) || !config) { - return ( -
-

GraphQL Source

-

Loading configuration…

-
- ); - } - - return ( - - ); -} diff --git a/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx b/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx index 3a152c3e9..4bb0b22df 100644 --- a/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx +++ b/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx @@ -4,6 +4,7 @@ import { CardStackEntryField, } from "@executor-js/react/components/card-stack"; import { Input } from "@executor-js/react/components/input"; +import { Textarea } from "@executor-js/react/components/textarea"; import { IntegrationIdentityFieldRows, type IntegrationIdentity, @@ -13,6 +14,10 @@ export function GraphqlSourceFields(props: { readonly endpoint: string; readonly onEndpointChange: (endpoint: string) => void; readonly identity: IntegrationIdentity; + /** The integration's agent-visible description. Blank = the backend falls + * back to the introspected schema's own description, then the name. */ + readonly description?: string; + readonly onDescriptionChange?: (value: string) => void; readonly endpointDisabled?: boolean; readonly namespaceReadOnly?: boolean; }) { @@ -36,6 +41,21 @@ export function GraphqlSourceFields(props: { namePlaceholder="e.g. Shopify API" namespaceReadOnly={props.namespaceReadOnly} /> + {props.onDescriptionChange && ( + +