diff --git a/.changeset/tool-call-log.md b/.changeset/tool-call-log.md new file mode 100644 index 0000000000..54370afa4f --- /dev/null +++ b/.changeset/tool-call-log.md @@ -0,0 +1,13 @@ +--- +"executor": minor +--- + +**New: an audit trail of every tool call — which integration an agent used, when, and how it ended** + +Executor kept no record of tool usage. A run that called GitHub or Search Console left one HTTP line (`POST /mcp 200`) and nothing about which integration, which tool, or what came back; the analytics catalog is anonymous by construction and deliberately drops exactly those fields. That made two questions unanswerable after the fact: what did this agent touch, and what did my policies actually stop. + +Every call through `execute` now writes a row: the address as called, its integration/connection/tool, the outcome, the policy that governed it, and how long it took. The rows that matter most are the ones with no other trace — a call a `block` policy stopped, and an approval someone declined, both of which end before any request is made. Read them from `executor.toolCalls.list()`, from `GET /api/tool-calls` (filter by integration, connection, outcome or time), or on the new **Activity** page in the console. + +Arguments, results, and any text that came from outside are never stored: an argument can be a credential, and an upstream error message routinely echoes the request back. A failed call keeps its upstream `code`, never its message; a call keeps its top-level argument _names_, and only those that look like parameters rather than payloads. Writing a row can never fail a call, and never delays one by more than its own timeout. + +Retention is left to the host: `executor.toolCalls.prune({ before })` removes old rows, and nothing schedules it for you — an audit log that quietly deletes itself on a default nobody chose is worse than one that grows. diff --git a/apps/cloud/drizzle/0016_nosy_expediter.sql b/apps/cloud/drizzle/0016_nosy_expediter.sql new file mode 100644 index 0000000000..7f677f314f --- /dev/null +++ b/apps/cloud/drizzle/0016_nosy_expediter.sql @@ -0,0 +1,21 @@ +CREATE TABLE "tool_call_log" ( + "id" varchar(255) NOT NULL, + "address" text NOT NULL, + "integration" varchar(255), + "connection" varchar(255), + "tool" text, + "outcome" varchar(255) NOT NULL, + "error_code" text, + "error_message" text, + "policy_action" text, + "policy_pattern" text, + "duration_ms" bigint NOT NULL, + "arg_keys" json, + "created_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "tool_call_log_uidx" ON "tool_call_log" USING btree ("tenant","owner","subject","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0016_snapshot.json b/apps/cloud/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000000..2ea65a0d88 --- /dev/null +++ b/apps/cloud/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1635 @@ +{ + "id": "4a6c7989-d75d-4ce8-bbbf-915eee0fa291", + "prevId": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55", + "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.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "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": { + "artifact_uidx": { + "name": "artifact_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 + }, + "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 + }, + "last_health": { + "name": "last_health", + "type": "json", + "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 + }, + "oauth_token_url": { + "name": "oauth_token_url", + "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": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "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 + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "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.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_call_log": { + "name": "tool_call_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_action": { + "name": "policy_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_pattern": { + "name": "policy_pattern", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "arg_keys": { + "name": "arg_keys", + "type": "json", + "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": { + "tool_call_log_uidx": { + "name": "tool_call_log_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 + }, + "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 fa90570831..7872101189 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1785355354955, "tag": "0015_equal_the_leader", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1786106210525, + "tag": "0016_nosy_expediter", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index e23d30d077..8462738cd3 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -226,6 +226,35 @@ export const tool_policy = pgTable( ], ); +export const tool_call_log = pgTable( + "tool_call_log", + { + id: varchar("id", { length: 255 }).notNull(), + address: text("address").notNull(), + integration: varchar("integration", { length: 255 }), + connection: varchar("connection", { length: 255 }), + tool: text("tool"), + outcome: varchar("outcome", { length: 255 }).notNull(), + error_code: text("error_code"), + error_message: text("error_message"), + policy_action: text("policy_action"), + policy_pattern: text("policy_pattern"), + duration_ms: bigint("duration_ms", { mode: "bigint" }).notNull(), + arg_keys: json("arg_keys"), + created_at: timestamp("created_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + }, + (table) => [ + uniqueIndex("tool_call_log_uidx").on(table.tenant, table.owner, table.subject, table.id), + ], +); + export const artifact = pgTable( "artifact", { diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index d139110935..a02708dd04 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as OrgRouteImport } from './routes/app/org' import { Route as BillingRouteImport } from './routes/app/billing' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport } from './../../../packages/react/src/routes/activity' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' @@ -101,6 +102,12 @@ const ApiKeysRoute = ApiKeysRouteImport.update({ path: '/{-$orgSlug}/api-keys', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport.update({ + id: '/{-$orgSlug}/activity', + path: '/{-$orgSlug}/activity', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( { @@ -158,6 +165,7 @@ export interface FileRoutesByFullPath { '/create-org': typeof CreateOrgRoute '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute @@ -180,6 +188,7 @@ export interface FileRoutesByTo { '/create-org': typeof CreateOrgRoute '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute @@ -203,6 +212,7 @@ export interface FileRoutesById { '/create-org': typeof CreateOrgRoute '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute @@ -227,6 +237,7 @@ export interface FileRouteTypes { | '/create-org' | '/login' | '/setup-mcp' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' @@ -249,6 +260,7 @@ export interface FileRouteTypes { | '/create-org' | '/login' | '/setup-mcp' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' @@ -271,6 +283,7 @@ export interface FileRouteTypes { | '/create-org' | '/login' | '/setup-mcp' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' @@ -294,6 +307,7 @@ export interface RootRouteChildren { CreateOrgRoute: typeof CreateOrgRoute LoginRoute: typeof LoginRoute SetupMcpRoute: typeof SetupMcpRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute ApiKeysRoute: typeof ApiKeysRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren BillingRoute: typeof BillingRoute @@ -404,6 +418,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiKeysRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/activity': { + id: '/{-$orgSlug}/activity' + path: '/{-$orgSlug}/activity' + fullPath: '/{-$orgSlug}/activity' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -490,6 +511,8 @@ const rootRouteChildren: RootRouteChildren = { CreateOrgRoute: CreateOrgRoute, LoginRoute: LoginRoute, SetupMcpRoute: SetupMcpRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute, ApiKeysRoute: ApiKeysRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, @@ -520,11 +543,15 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' + import type { startInstance } from './start.ts' + declare module '@tanstack/react-start' { interface Register { ssr: true + router: Awaited> + config: Awaited> } } diff --git a/apps/host-cloudflare/web/routeTree.gen.ts b/apps/host-cloudflare/web/routeTree.gen.ts index 2acc6bbf2f..a738f91cd8 100644 --- a/apps/host-cloudflare/web/routeTree.gen.ts +++ b/apps/host-cloudflare/web/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImp import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport } from './../../../packages/react/src/routes/activity' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' @@ -59,6 +60,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = path: '/{-$orgSlug}/artifacts', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport.update({ + id: '/{-$orgSlug}/activity', + path: '/{-$orgSlug}/activity', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( { @@ -119,6 +126,7 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK ) export interface FileRoutesByFullPath { + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute @@ -134,6 +142,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute @@ -150,6 +159,7 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute @@ -167,6 +177,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -182,6 +193,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -197,6 +209,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -213,6 +226,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute @@ -270,6 +284,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/activity': { + id: '/{-$orgSlug}/activity' + path: '/{-$orgSlug}/activity' + fullPath: '/{-$orgSlug}/activity' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -353,6 +374,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = ) const rootRouteChildren: RootRouteChildren = { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index e2d2df6248..d63060d5f6 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as AdminRouteImport } from './routes/app/admin' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport } from './../../../packages/react/src/routes/activity' import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' @@ -79,6 +80,12 @@ const AdminRoute = AdminRouteImport.update({ path: '/{-$orgSlug}/admin', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport.update({ + id: '/{-$orgSlug}/activity', + path: '/{-$orgSlug}/activity', + getParentRoute: () => rootRouteImport, + } as any) const JoinDotcodeRoute = JoinDotcodeRouteImport.update({ id: '/join/$code', path: '/join/$code', @@ -145,6 +152,7 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK export interface FileRoutesByFullPath { '/join/$code': typeof JoinDotcodeRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -164,6 +172,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/join/$code': typeof JoinDotcodeRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -184,6 +193,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/join/$code': typeof JoinDotcodeRoute + '/{-$orgSlug}/activity': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -205,6 +215,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/join/$code' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' @@ -224,6 +235,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/join/$code' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' @@ -243,6 +255,7 @@ export interface FileRouteTypes { id: | '__root__' | '/join/$code' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' @@ -263,6 +276,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { JoinDotcodeRoute: typeof JoinDotcodeRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute AdminRoute: typeof AdminRoute ApiKeysRoute: typeof ApiKeysRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -344,6 +358,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/activity': { + id: '/{-$orgSlug}/activity' + path: '/{-$orgSlug}/activity' + fullPath: '/{-$orgSlug}/activity' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRouteImport + parentRoute: typeof rootRouteImport + } '/join/$code': { id: '/join/$code' path: '/join/$code' @@ -435,6 +456,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = const rootRouteChildren: RootRouteChildren = { JoinDotcodeRoute: JoinDotcodeRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesActivityRoute, AdminRoute: AdminRoute, ApiKeysRoute: ApiKeysRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: diff --git a/packages/core/api/src/account/org-slug.ts b/packages/core/api/src/account/org-slug.ts index addd842831..4b70b7a1ec 100644 --- a/packages/core/api/src/account/org-slug.ts +++ b/packages/core/api/src/account/org-slug.ts @@ -46,6 +46,7 @@ export const RESERVED_ORG_SLUGS: ReadonlySet = new Set([ "connect", "integrations", "policies", + "activity", "secrets", "tools", "toolkits", diff --git a/packages/core/api/src/api.ts b/packages/core/api/src/api.ts index 4bbe145e23..ef01ed92c9 100644 --- a/packages/core/api/src/api.ts +++ b/packages/core/api/src/api.ts @@ -9,6 +9,7 @@ import { ExecutionsApi } from "./executions/api"; import { OAuthApi } from "./oauth/api"; import { PoliciesApi } from "./policies/api"; import { ArtifactsApi } from "./artifacts/api"; +import { ToolCallsApi } from "./tool-calls/api"; export const CoreExecutorApi = HttpApi.make("executor") .add(ToolsApi) @@ -19,6 +20,7 @@ export const CoreExecutorApi = HttpApi.make("executor") .add(OAuthApi) .add(PoliciesApi) .add(ArtifactsApi) + .add(ToolCallsApi) .annotateMerge( OpenApi.annotations({ title: "Executor API", diff --git a/packages/core/api/src/handlers/index.ts b/packages/core/api/src/handlers/index.ts index 360952bd7d..6e5528a4ac 100644 --- a/packages/core/api/src/handlers/index.ts +++ b/packages/core/api/src/handlers/index.ts @@ -8,6 +8,7 @@ import { ExecutionsHandlers } from "./executions"; import { OAuthHandlers } from "./oauth"; import { PoliciesHandlers } from "./policies"; import { ArtifactsHandlers } from "./artifacts"; +import { ToolCallsHandlers } from "./tool-calls"; export { ToolsHandlers } from "./tools"; export { IntegrationsHandlers } from "./integrations"; @@ -17,6 +18,7 @@ export { ExecutionsHandlers } from "./executions"; export { OAuthHandlers } from "./oauth"; export { PoliciesHandlers } from "./policies"; export { ArtifactsHandlers } from "./artifacts"; +export { ToolCallsHandlers } from "./tool-calls"; export const CoreHandlers = Layer.mergeAll( ToolsHandlers, @@ -27,4 +29,5 @@ export const CoreHandlers = Layer.mergeAll( OAuthHandlers, PoliciesHandlers, ArtifactsHandlers, + ToolCallsHandlers, ); diff --git a/packages/core/api/src/handlers/tool-calls.ts b/packages/core/api/src/handlers/tool-calls.ts new file mode 100644 index 0000000000..9f46049709 --- /dev/null +++ b/packages/core/api/src/handlers/tool-calls.ts @@ -0,0 +1,43 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; +import type { ToolCall } from "@executor-js/sdk"; + +import { ExecutorApi } from "../api"; +import { ExecutorService } from "../services"; +import { capture } from "@executor-js/api"; + +const toResponse = (call: ToolCall) => ({ + id: call.id, + owner: call.owner, + address: call.address, + integration: call.integration, + connection: call.connection, + tool: call.tool, + outcome: call.outcome, + errorCode: call.errorCode, + errorMessage: call.errorMessage, + policyAction: call.policyAction, + policyPattern: call.policyPattern, + durationMs: call.durationMs, + argKeys: call.argKeys, + createdAt: call.createdAt.getTime(), +}); + +export const ToolCallsHandlers = HttpApiBuilder.group(ExecutorApi, "toolCalls", (handlers) => + handlers.handle("list", ({ query }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const calls = yield* executor.toolCalls.list({ + integration: query.integration, + connection: query.connection, + outcome: query.outcome, + // Epoch ms on the wire; the executor filters on a Date. + since: query.since === undefined ? undefined : new Date(query.since), + limit: query.limit, + }); + return calls.map(toResponse); + }), + ), + ), +); diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 104d841c39..56d15d2105 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -13,6 +13,7 @@ export { OAuthHandlers, PoliciesHandlers, ArtifactsHandlers, + ToolCallsHandlers, ExecutionsHandlers, } from "./handlers"; export { diff --git a/packages/core/api/src/tool-calls/api.ts b/packages/core/api/src/tool-calls/api.ts new file mode 100644 index 0000000000..38f391c988 --- /dev/null +++ b/packages/core/api/src/tool-calls/api.ts @@ -0,0 +1,65 @@ +// --------------------------------------------------------------------------- +// Tool call log HTTP API — the audit trail. +// +// One row per tool call that reached the executor, including the ones a policy +// blocked and the approvals a caller declined. Read-only by construction: a +// log a caller can edit is not evidence, so there is no write endpoint and no +// delete. Owner-scoped like the rest of the API, so no owner travels on the +// wire — a caller reads back exactly the calls its own scope may see. +// --------------------------------------------------------------------------- + +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { InternalError, Owner } from "@executor-js/sdk/shared"; +import { TOOL_CALL_LIST_MAX_LIMIT } from "@executor-js/sdk"; + +const ToolCallOutcome = Schema.Literals(["ok", "fail", "blocked", "declined", "error"]); + +const ToolCallResponse = Schema.Struct({ + id: Schema.String, + owner: Owner, + /** The address as called, e.g. `github.org.main.repos.get`. */ + address: Schema.String, + /** Null for static tools, which have no connection behind them. */ + integration: Schema.NullOr(Schema.String), + connection: Schema.NullOr(Schema.String), + tool: Schema.NullOr(Schema.String), + outcome: ToolCallOutcome, + errorCode: Schema.NullOr(Schema.String), + errorMessage: Schema.NullOr(Schema.String), + /** The policy that governed the call, when a rule matched it. */ + policyAction: Schema.NullOr(Schema.String), + policyPattern: Schema.NullOr(Schema.String), + durationMs: Schema.Number, + /** Top-level argument names. Never their values — see `tool-call-log.ts`. */ + argKeys: Schema.NullOr(Schema.Array(Schema.String)), + /** Epoch milliseconds, like every other timestamp on this API. */ + createdAt: Schema.Number, +}); + +/** + * Query filters. + * + * `since` is epoch milliseconds rather than a date string: it is what the + * other endpoints already put on the wire, and it survives a round trip + * through a URL without a timezone argument. + */ +const ListToolCallsQuery = Schema.Struct({ + integration: Schema.optional(Schema.String), + connection: Schema.optional(Schema.String), + outcome: Schema.optional(ToolCallOutcome), + since: Schema.optional(Schema.FiniteFromString), + limit: Schema.optional( + Schema.FiniteFromString.check( + Schema.isBetween({ minimum: 1, maximum: TOOL_CALL_LIST_MAX_LIMIT }), + ), + ), +}); + +export const ToolCallsApi = HttpApiGroup.make("toolCalls").add( + HttpApiEndpoint.get("list", "/tool-calls", { + query: ListToolCallsQuery, + success: Schema.Array(ToolCallResponse), + error: InternalError, + }), +); diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index b03adf5dfc..1d775659d8 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -362,6 +362,60 @@ export const coreTables = defineTables({ ["tenant", "owner", "subject", "id"], ), + // One row per tool call that reached `execute`, written after the call + // settles. This is the audit trail: which connection an agent used, when, + // and how it ended — including the calls a policy blocked and the approvals + // a caller declined, which today leave no trace at all. + // + // Deliberately NOT the analytics catalog in `@executor-js/analytics`: that + // one is anonymous by construction and forbids exactly the fields an audit + // needs (tool address, integration, connection). Owner-scoped like every + // other personal row, so a subject reads back its own calls and nobody + // else's. + // + // Arguments and results are never stored. They carry the caller's data and, + // for a credential-shaped argument, the credential itself; `arg_keys` keeps + // the top-level parameter NAMES, which is what an audit needs to answer + // "what did it ask for" without the table becoming a secret store. + tool_call_log: ownedExecutorTable( + "tool_call_log", + { + id: keyColumn("id"), + // The address as called: `integration.owner.connection.tool`, or a + // static tool's fqid. Verbatim, so a row greps against what the agent + // actually wrote. + address: textColumn("address"), + // The parsed parts, so a report groups by integration or connection + // without re-parsing every address. Null for static tools (core-tools, + // plugin namespaces), which have no connection behind them. + integration: nullableKeyColumn("integration"), + connection: nullableKeyColumn("connection"), + tool: nullableTextColumn("tool"), + // ok | fail | blocked | declined | error — see ToolCallOutcome. + outcome: keyColumn("outcome"), + // For `fail` the upstream's own error code; otherwise the failure tag. + // Null when the call simply succeeded. + error_code: nullableTextColumn("error_code"), + // One line of human context, truncated. Never a response body. + error_message: nullableTextColumn("error_message"), + // The policy that governed this call, so the row shows both what + // happened and under which rule. Null when no rule matched. + policy_action: nullableTextColumn("policy_action"), + policy_pattern: nullableTextColumn("policy_pattern"), + // Wall-clock duration of the call, in milliseconds. + duration_ms: bigintColumn("duration_ms"), + // Top-level argument names only — never their values. + arg_keys: nullableJsonColumn("arg_keys"), + created_at: dateColumn("created_at"), + }, + // The conventional owned-table key. Note what it does NOT do: a subject's + // view spans two partitions (its own rows plus the org's), so a newest- + // first read across both still sorts. Serving that would take a + // `(tenant, created_at)` index, and the schema layer has no non-unique + // index yet — worth adding before this table gets large. + ["tenant", "owner", "subject", "id"], + ), + // A saved generative-UI artifact — the JSX source a model produced, kept so // it can be re-rendered later and matched by title/description from any MCP // client. Owner-scoped like every other personal row: artifacts are created @@ -457,6 +511,28 @@ export const TOOL_INVOCATION_COLUMNS = [ export type DefinitionRow = FumaRow; export type ToolPolicyRow = FumaRow; export type ArtifactRow = FumaRow; +export type ToolCallLogRow = FumaRow; + +/** + * How a tool call ended. + * + * `ok` and `fail` both mean the call reached the upstream service: `fail` is + * the tool's own error result (a 404 from the API, an expired credential), + * which travels the success channel and would otherwise read as healthy. + * `blocked` and `declined` mean it never left the gateway — a policy stopped + * it, or the human refused the approval. `error` is everything else: the tool + * or connection did not exist, the plugin could not be loaded, the transport + * broke. + */ +export type ToolCallOutcome = "ok" | "fail" | "blocked" | "declined" | "error"; + +export const TOOL_CALL_OUTCOMES = [ + "ok", + "fail", + "blocked", + "declined", + "error", +] as const satisfies readonly ToolCallOutcome[]; /** * The columns a list projects — everything except the JSX source, which only a * full read needs. diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 56cb2e2997..3553f5199c 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1,4 +1,4 @@ -import { Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect"; +import { Effect, Exit, Inspectable, Layer, Option, Predicate, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { fumadb } from "@executor-js/fumadb"; import { memoryAdapter } from "@executor-js/fumadb/adapters/memory"; @@ -17,6 +17,16 @@ import { } from "./fuma-runtime"; import { makeFumaBlobStore, pluginBlobStore, type BlobStore, type OwnerPartitions } from "./blob"; import { makePendingApprovalStore, type PendingApprovalStore } from "./pending-approval"; +import { + clampToolCallLimit, + rowToToolCall, + toolCallArgKeys, + toolCallOutcome, + TOOL_CALL_LOG_WRITE_TIMEOUT, + type ListToolCallsInput, + type PruneToolCallsInput, + type ToolCall, +} from "./tool-call-log"; import { coreToolsPlugin } from "./core-tools"; import type { Connection, @@ -394,6 +404,19 @@ export type Executor = { */ readonly admin?: ExecutorAdmin; /** Saved generative-UI artifacts, visible to the bound owner scope. */ + /** + * The audit trail: one row per tool call that reached `execute`, including + * the calls a policy blocked and the approvals a caller declined. + */ + readonly toolCalls: { + /** Newest first, filtered and capped — the log is unbounded. */ + readonly list: ( + input?: ListToolCallsInput, + ) => Effect.Effect; + /** Drop rows older than `before`. Retention is the host's policy to set. */ + readonly prune: (input: PruneToolCallsInput) => Effect.Effect; + }; + readonly artifacts: { /** Newest first, without the JSX source — lists stay light. */ readonly list: () => Effect.Effect; @@ -4045,6 +4068,142 @@ export const createExecutor = EffectivePolicy | null, + ) => { + const startedAt = Date.now(); + return (effect: Effect.Effect): Effect.Effect => + Effect.onExit(effect, (exit) => + writeToolCallRow({ + address, + args, + policy: policy(), + exit, + durationMs: Date.now() - startedAt, + }).pipe( + // A sick database must not become a sick gateway. The insert is + // awaited — a forked write would be interrupted when a per-request + // host tears the executor down, and a silently missing row is the + // one thing an audit log may not do — but it is awaited under a + // hard cap, so the worst a stalled write can cost a tool call is + // this timeout rather than the driver's own. + Effect.timeout(TOOL_CALL_LOG_WRITE_TIMEOUT), + // Never let the audit write decide the call's fate — including a + // defect. The row is the record OF the call, not part of it; a + // gap in the log beats taking the gateway down with it. + Effect.catchCause((cause) => + Effect.logWarning("tool call log: row not written", cause).pipe( + Effect.annotateLogs({ address: String(address) }), + ), + ), + ), + ); + }; + + const writeToolCallRow = ({ + address, + args, + policy, + exit, + durationMs, + }: { + readonly address: ToolAddress; + readonly args: unknown; + readonly policy: EffectivePolicy | null; + readonly exit: Exit.Exit; + readonly durationMs: number; + }): Effect.Effect => + Effect.gen(function* () { + const parsed = parseToolAddress(String(address)); + // A call is logged under the owner tier it targeted, so an org + // connection's calls stay visible org-wide and a user's stay personal. + // A static tool has no owner in its address; it belongs to whoever ran + // it, falling back to the org scope for a subject-less executor. + const tier: Owner = parsed ? parsed.owner : subject == null ? "org" : "user"; + const keys = yield* Effect.try({ + try: () => ownedKeys(tier), + catch: (cause) => storageFailureFromUnknown("invalid owner", cause), + }); + const outcome = toolCallOutcome(exit); + yield* core.create("tool_call_log", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: `tcl_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, + address: String(address), + integration: parsed ? String(parsed.integration) : null, + connection: parsed ? String(parsed.connection) : null, + tool: parsed ? String(parsed.tool) : null, + outcome: outcome.outcome, + error_code: outcome.errorCode, + error_message: outcome.errorMessage, + policy_action: policy?.action ?? null, + policy_pattern: policy?.pattern ?? null, + duration_ms: durationMs, + arg_keys: toolCallArgKeys(args), + created_at: new Date(), + }); + }); + + const toolCallLogList = ( + input?: ListToolCallsInput, + ): Effect.Effect => + core + .findMany("tool_call_log", { + where: toolCallLogWhere(input), + // Newest first; `id` breaks ties so two calls landing in the same + // millisecond keep a stable order between reads. + orderBy: [ + ["created_at", "desc"], + ["id", "desc"], + ], + limit: clampToolCallLimit(input?.limit), + }) + .pipe(Effect.map((rows) => rows.map(rowToToolCall))); + + /** + * Drop rows older than `before`. + * + * Retention is the host's call, not this package's: a self-hosted box and a + * regulated tenant want different windows, and an audit log that quietly + * deletes itself on a default nobody chose is worse than one that grows. + * So the executor exposes the operation and never schedules it. + */ + const toolCallLogPrune = (input: PruneToolCallsInput): Effect.Effect => + core.deleteMany("tool_call_log", { + where: (b: AnyCb) => b("created_at", "<", input.before), + }); + + const toolCallLogWhere = (input?: ListToolCallsInput): CoreWhere | undefined => { + const clauses: readonly ((b: AnyCb) => Condition)[] = [ + ...(input?.integration ? [(b: AnyCb) => b("integration", "=", input.integration!)] : []), + ...(input?.connection ? [(b: AnyCb) => b("connection", "=", input.connection!)] : []), + ...(input?.outcome ? [(b: AnyCb) => b("outcome", "=", input.outcome!)] : []), + ...(input?.since ? [(b: AnyCb) => b("created_at", ">=", input.since!)] : []), + ]; + if (clauses.length === 0) return undefined; + return (b: AnyCb) => + clauses.length === 1 ? clauses[0]!(b) : b.and(...clauses.map((clause) => clause(b))); + }; + // ------------------------------------------------------------------ // Artifacts — saved generative-UI components, owner-scoped. // ------------------------------------------------------------------ @@ -4295,6 +4454,10 @@ export const createExecutor = => { const handler = pickHandler(options); + // The policy that governed this call, filled in as soon as it resolves. + // Per-call state: `execute` is re-entered for every invocation, so this + // binding is never shared between concurrent calls. + let governingPolicy: EffectivePolicy | null = null; return Effect.gen(function* () { // oxlint-disable executor/no-instanceof-error, executor/no-unknown-error-message, executor/no-manual-tag-check -- boundary: normalize arbitrary unknown plugin failures into a human-readable message for ToolInvocationError/telemetry const formatInvocationCauseMessage = (cause: unknown): string => { @@ -4335,6 +4498,7 @@ export const createExecutor = governingPolicy), ); }; @@ -4944,6 +5114,7 @@ export const createExecutor = { + it("records a plain success as ok", () => { + expect(toolCallOutcome(Exit.succeed({ ran: true }))).toEqual({ + outcome: "ok", + errorCode: null, + errorMessage: null, + }); + }); + + it("records a ToolResult.fail as fail, not ok", () => { + // The case the span-only telemetry gets wrong: an expected tool failure + // travels the SUCCESS channel, so anything that looks at the Effect + // channel alone reports an upstream 404 as a healthy call. + const summary = toolCallOutcome( + Exit.succeed(ToolResult.fail({ code: "http_error", message: "404 Not Found", status: 404 })), + ); + expect(summary.outcome).toBe("fail"); + expect(summary.errorCode).toBe("http_error"); + }); + + it("keeps the upstream's code and never its message", () => { + // Plugins derive that message from the upstream response body, which + // routinely echoes the request back — including whatever was in it. + const summary = toolCallOutcome( + Exit.succeed( + ToolResult.fail({ + code: "http_error", + message: "invalid token ghp_averyrealsecrettoken for user@example.com", + }), + ), + ); + expect(summary.errorCode).toBe("http_error"); + expect(summary.errorMessage).toBeNull(); + expect(JSON.stringify(summary)).not.toContain("ghp_averyrealsecrettoken"); + }); + + it("records a decline a tool handler raised, not a generic error", () => { + // `execute` wraps any handler failure in ToolInvocationError on the way + // out, so the decline arrives one level down. + const declined = new ElicitationDeclinedError({ + address: ToolAddress.make("tools.github.org.main.delete"), + action: "decline", + }); + const summary = toolCallOutcome( + Exit.fail( + new ToolInvocationError({ + address: ToolAddress.make("tools.github.org.main.delete"), + message: "declined", + cause: declined, + }), + ), + ); + expect(summary.outcome).toBe("declined"); + }); + + it("drops an error code that is not shaped like one", () => { + // `ToolError.code` is typed as any string, so a plugin can forward an + // upstream body into it. The outcome already says what happened. + const summary = toolCallOutcome( + Exit.succeed( + ToolResult.fail({ + code: '{"error":"invalid_grant","token":"ghp_averyrealsecrettoken"}', + message: "upstream said no", + }), + ), + ); + expect(summary.outcome).toBe("fail"); + expect(summary.errorCode).toBeNull(); + expect(JSON.stringify(summary)).not.toContain("ghp_averyrealsecrettoken"); + }); + + it("records a defect as an error rather than losing the call", () => { + // The point of the test is an untyped throw from outside the Effect domain. + // oxlint-disable-next-line executor/no-error-constructor -- boundary: simulating a defect + const summary = toolCallOutcome(Exit.failCause(Cause.die(new Error("boom")))); + expect(summary.outcome).toBe("error"); + // The tag, never the message: a defect's text comes from outside. + expect(summary.errorMessage).toBeNull(); + }); +}); + +describe("toolCallArgKeys", () => { + it("keeps the names and never the values", () => { + const keys = toolCallArgKeys({ siteUrl: "sc-domain:example.com", token: "s3cr3t" }); + expect(keys).toEqual(["siteUrl", "token"]); + expect(JSON.stringify(keys)).not.toContain("s3cr3t"); + }); + + it("has nothing to say about absent or non-object arguments", () => { + expect(toolCallArgKeys(undefined)).toBeNull(); + expect(toolCallArgKeys("just a string")).toBeNull(); + expect(toolCallArgKeys([1, 2, 3])).toBeNull(); + expect(toolCallArgKeys({})).toBeNull(); + }); + + it("caps a pathological argument map", () => { + const args = Object.fromEntries(Array.from({ length: 500 }, (_, i) => [`k${i}`, i])); + expect(toolCallArgKeys(args)).toHaveLength(TOOL_CALL_ARG_KEY_LIMIT); + }); + + it("drops names that are payloads or credentials rather than parameters", () => { + // `execute` takes `unknown` arguments, so a KEY is caller-controlled too. + const keys = toolCallArgKeys({ + siteUrl: "ok", + ghp_averyrealsecrettoken: null, + ["x".repeat(500)]: 1, + eyJhbGciOiJIUzI1NiJ9: 1, + "0123456789abcdef0123456789abcdef": 1, + '{"nested":"json"}': 1, + }); + expect(keys).toEqual(["siteUrl"]); + }); +}); + +describe("clampToolCallLimit", () => { + it("defaults, floors and caps", () => { + expect(clampToolCallLimit(undefined)).toBe(TOOL_CALL_LIST_DEFAULT_LIMIT); + expect(clampToolCallLimit(Number.NaN)).toBe(TOOL_CALL_LIST_DEFAULT_LIMIT); + expect(clampToolCallLimit(0)).toBe(1); + expect(clampToolCallLimit(25)).toBe(25); + expect(clampToolCallLimit(10_000)).toBe(TOOL_CALL_LIST_MAX_LIMIT); + }); +}); + +// --------------------------------------------------------------------------- +// Executor integration — every ending a call can have must leave a row. +// --------------------------------------------------------------------------- + +const memoryProvider = (): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + }; +}; + +const GITHUB = IntegrationSlug.make("github"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); +const CONN = ConnectionName.make("main"); + +const addr = (toolName: string): ToolAddress => + ToolAddress.make(`tools.${GITHUB}.org.${CONN}.${toolName}`); + +const logTestPlugin = definePlugin(() => ({ + id: "logtest" as const, + storage: () => ({}), + credentialProviders: [memoryProvider()], + resolveTools: () => + Effect.succeed({ + tools: [ + { name: ToolName.make("get"), description: "read a repo" }, + { name: ToolName.make("missing"), description: "always 404s upstream" }, + { + name: ToolName.make("delete"), + description: "delete a repo", + annotations: { requiresApproval: true }, + }, + ], + }), + invokeTool: ({ toolRow }) => + toolRow.name === "missing" + ? Effect.succeed(ToolResult.fail({ code: "http_error", message: "404", status: 404 })) + : Effect.succeed(ToolResult.ok({ ran: toolRow.name })), + extension: (ctx) => ({ + seed: () => ctx.core.integrations.register({ slug: GITHUB, description: "GitHub", config: {} }), + }), +})); + +const decliningHandler: ElicitationHandler = () => + Effect.succeed(ElicitationResponse.make({ action: "decline" })); + +const setupExecutor = () => + makeTestExecutor({ plugins: [logTestPlugin()] as const }).pipe( + Effect.tap((executor) => + Effect.gen(function* () { + yield* executor.logtest.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: GITHUB, + template: TEMPLATE, + from: { provider: ProviderKey.make("memory"), id: ProviderItemId.make("g") }, + }); + }), + ), + ); + +describe("executor.toolCalls", () => { + it.effect("is empty before anything runs", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + expect(yield* executor.toolCalls.list()).toEqual([]); + }), + ); + + it.effect("records a successful call with its address, policy and duration", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* executor.execute(addr("get"), { owner: "midego1", repo: "hermes-box" }); + + const calls = yield* executor.toolCalls.list(); + expect(calls).toHaveLength(1); + const [call] = calls; + expect(call?.address).toBe(String(addr("get"))); + expect(call?.integration).toBe("github"); + expect(call?.connection).toBe("main"); + expect(call?.tool).toBe("get"); + expect(call?.outcome).toBe("ok"); + expect(call?.errorCode).toBeNull(); + expect(call?.argKeys).toEqual(["owner", "repo"]); + expect(call?.durationMs).toBeGreaterThanOrEqual(0); + }), + ); + + it.effect("never stores argument values, only their names", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* executor.execute(addr("get"), { token: "ghp_averyrealsecrettoken" }); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.argKeys).toEqual(["token"]); + expect(JSON.stringify(call)).not.toContain("ghp_averyrealsecrettoken"); + }), + ); + + it.effect("records an upstream failure as fail, with the upstream's own code", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* executor.execute(addr("missing"), {}); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.outcome).toBe("fail"); + expect(call?.errorCode).toBe("http_error"); + }), + ); + + it.effect("records a call a policy blocked — the one that leaves no other trace", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* executor.policies.create({ owner: "org", pattern: "github.*.*.get", action: "block" }); + yield* Effect.result(executor.execute(addr("get"), {})); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.outcome).toBe("blocked"); + expect(call?.errorCode).toBe("tool_blocked"); + expect(call?.policyAction).toBe("block"); + expect(call?.policyPattern).toBe("github.*.*.get"); + }), + ); + + it.effect("records an approval the caller declined", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* Effect.result( + executor.execute(addr("delete"), {}, { onElicitation: decliningHandler }), + ); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.outcome).toBe("declined"); + expect(call?.errorCode).toBe("approval_declined"); + }), + ); + + it.effect("records a call to a tool that does not exist", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* Effect.result(executor.execute(addr("nope"), {})); + + const [call] = yield* executor.toolCalls.list(); + expect(call?.outcome).toBe("error"); + expect(call?.errorCode).toBe("ToolNotFoundError"); + }), + ); + + it.effect("lists newest first and filters by integration, outcome and limit", () => + Effect.gen(function* () { + const executor = yield* setupExecutor(); + yield* executor.execute(addr("get"), {}); + yield* executor.execute(addr("missing"), {}); + yield* executor.execute(addr("get"), {}); + + const all = yield* executor.toolCalls.list(); + expect(all).toHaveLength(3); + + const failures = yield* executor.toolCalls.list({ outcome: "fail" }); + expect(failures).toHaveLength(1); + expect(failures[0]?.tool).toBe("missing"); + + const byIntegration = yield* executor.toolCalls.list({ integration: "github" }); + expect(byIntegration).toHaveLength(3); + expect(yield* executor.toolCalls.list({ integration: "gsc" })).toEqual([]); + + const limited = yield* executor.toolCalls.list({ limit: 2 }); + expect(limited).toHaveLength(2); + }), + ); +}); diff --git a/packages/core/sdk/src/tool-call-log.ts b/packages/core/sdk/src/tool-call-log.ts new file mode 100644 index 0000000000..401b6a3508 --- /dev/null +++ b/packages/core/sdk/src/tool-call-log.ts @@ -0,0 +1,249 @@ +/** + * The tool call log — how a settled call becomes an audit row. + * + * Only the pure shape lives here (outcome classification, argument-name + * extraction, row mapping); the write itself is in `executor.ts`, wrapped + * around `execute` so every ending a call can have passes through it. + * + * What this deliberately does NOT keep: arguments, results, and any text that + * came from outside. They carry the caller's data, and a credential-shaped + * argument carries the credential. Two rules make that concrete: + * + * 1. Only messages this file WROTE are stored. An upstream failure keeps its + * `code` — an enumerable identifier — never its message, which plugins + * derive from upstream error bodies and which routinely echoes back the + * request (and with it, whatever was in it). + * 2. Argument NAMES are stored, values never — and a name only survives if + * it looks like a parameter rather than a payload. + * + * Compare `@executor-js/analytics`, which is anonymous by construction and + * drops the address/integration this table exists to keep. + */ + +import { Cause, Exit, Predicate } from "effect"; + +import { isToolResult } from "./tool-result"; +import type { Owner } from "./ids"; +import type { ToolCallLogRow, ToolCallOutcome } from "./core-schema"; + +/** One recorded call, as callers read it back. */ +export interface ToolCall { + readonly id: string; + readonly owner: Owner; + /** The address as called, e.g. `github.org.main.repos.get`. */ + readonly address: string; + /** Null for static tools (core-tools, plugin namespaces). */ + readonly integration: string | null; + readonly connection: string | null; + readonly tool: string | null; + readonly outcome: ToolCallOutcome; + readonly errorCode: string | null; + readonly errorMessage: string | null; + /** The policy that governed the call, when a rule matched. */ + readonly policyAction: string | null; + readonly policyPattern: string | null; + readonly durationMs: number; + /** Top-level argument names, never their values. */ + readonly argKeys: readonly string[] | null; + readonly createdAt: Date; +} + +export interface ListToolCallsInput { + readonly integration?: string; + readonly connection?: string; + readonly outcome?: ToolCallOutcome; + /** Only calls at or after this instant. */ + readonly since?: Date; + readonly limit?: number; +} + +export interface PruneToolCallsInput { + /** Rows created strictly before this instant are removed. */ + readonly before: Date; +} + +/** + * The longest a tool call may wait on its own audit row. + * + * The write is awaited (see `executor.ts`) so a per-request host cannot tear + * the executor down mid-insert and lose the row. This is the cap on what that + * choice can cost when the database is unwell: past it the row is dropped with + * a warning and the call returns. + */ +export const TOOL_CALL_LOG_WRITE_TIMEOUT = "2 seconds"; + +export const TOOL_CALL_LIST_DEFAULT_LIMIT = 100; +export const TOOL_CALL_LIST_MAX_LIMIT = 1000; + +/** A log read is a page, never the whole table: the log grows without bound. */ +export const clampToolCallLimit = (limit: number | undefined): number => { + if (limit == null || !Number.isFinite(limit)) return TOOL_CALL_LIST_DEFAULT_LIMIT; + return Math.min(TOOL_CALL_LIST_MAX_LIMIT, Math.max(1, Math.floor(limit))); +}; + +/** One line of context, never a response body. */ +export const TOOL_CALL_MESSAGE_LIMIT = 300; + +/** + * What an error code may look like before it is stored. + * + * `ToolError.code` is typed as any string, so a plugin is free to forward an + * upstream identifier — or an upstream body — straight into it. An audit row + * keeps codes because they are enumerable labels; anything that is not shaped + * like one is dropped rather than persisted, since the outcome column already + * says what happened. + */ +const ERROR_CODE = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/; + +const safeCode = (code: unknown): string | null => + typeof code === "string" && ERROR_CODE.test(code) ? code : null; +/** Enough parameter names to recognise a call; a pathological arg map is cut. */ +export const TOOL_CALL_ARG_KEY_LIMIT = 64; + +const truncate = (message: string): string => + message.length > TOOL_CALL_MESSAGE_LIMIT + ? `${message.slice(0, TOOL_CALL_MESSAGE_LIMIT)}…` + : message; + +export interface ToolCallOutcomeSummary { + readonly outcome: ToolCallOutcome; + readonly errorCode: string | null; + readonly errorMessage: string | null; +} + +/** + * Classify how a call ended. + * + * The subtle case is `fail`: a tool's own error result rides the SUCCESS + * channel by design (expected failures are values, not defects), so a log + * that only looked at the Effect channel would record an upstream 404 or an + * expired credential as a healthy call. + */ +export const toolCallOutcome = (exit: Exit.Exit): ToolCallOutcomeSummary => { + if (Exit.isSuccess(exit)) { + const value = exit.value; + if (isToolResult(value) && !value.ok) { + // The code only, and only if it is shaped like one. `error.message` is + // upstream text — the OpenAPI plugin lifts it straight out of the + // response body — so storing it would make an upstream echo of a token + // or a customer record durable, and readable through the log API. + return { outcome: "fail", errorCode: safeCode(value.error.code), errorMessage: null }; + } + return { outcome: "ok", errorCode: null, errorMessage: null }; + } + + const failure = unwrapInvocationCause(Exit.isFailure(exit) ? causeFailure(exit) : undefined); + if (Predicate.isTagged("ToolBlockedError")(failure)) { + const pattern = (failure as { readonly pattern?: unknown }).pattern; + return { + outcome: "blocked", + errorCode: "tool_blocked", + errorMessage: typeof pattern === "string" ? truncate(`blocked by pattern ${pattern}`) : null, + }; + } + if (Predicate.isTagged("ElicitationDeclinedError")(failure)) { + const action = (failure as { readonly action?: unknown }).action; + return { + outcome: "declined", + errorCode: "approval_declined", + errorMessage: typeof action === "string" ? truncate(`approval ${action}`) : null, + }; + } + // Everything else keeps its TAG and nothing more: a plugin's failure message + // is upstream text under the same rule as `fail` above. + return { outcome: "error", errorCode: failureTag(failure), errorMessage: null }; +}; + +/** + * A tool handler may itself raise a decline (the `elicit` capability is handed + * to handlers, not only to the executor), and `execute` wraps any handler + * failure in a `ToolInvocationError` on its way out. Unwrap one level so that + * decline is recorded as a decline instead of a generic error. + */ +const unwrapInvocationCause = (failure: unknown): unknown => { + if (!Predicate.isTagged("ToolInvocationError")(failure)) return failure; + const inner = (failure as { readonly cause?: unknown }).cause; + return inner ?? failure; +}; + +// A defect (an unexpected throw) is as much an audit fact as a typed failure: +// the call ended and the caller got nothing back. `squash` gives the first +// failure or defect in the cause, whichever the call actually died of. +const causeFailure = (exit: Exit.Failure): unknown => Cause.squash(exit.cause); + +// An audit row labels whatever the call died of, including failures this +// package has never heard of — there is no tag to match against ahead of time, +// which is what the rule below normally protects. +const failureTag = (failure: unknown): string | null => { + if (failure == null || typeof failure !== "object") return null; + // oxlint-disable-next-line executor/no-manual-tag-check -- boundary: labelling an unknown failure for the audit log + const tag = (failure as { readonly _tag?: unknown })._tag; + // Tags are this codebase's own labels, but the same shape rule applies: a + // failure can come from anywhere, including a plugin that put text in one. + return safeCode(tag); +}; + +/** The longest a parameter name can plausibly be. */ +export const TOOL_CALL_ARG_KEY_MAX_LENGTH = 64; + +/** What a tool parameter looks like: an identifier, not a payload. */ +const PARAMETER_NAME = /^[A-Za-z_][A-Za-z0-9_.[\]-]*$/; + +/** Names that are themselves shaped like a credential — a caller can put + * anything in a key, and `execute` accepts `unknown` arguments. */ +const CREDENTIAL_SHAPED = + /^(?:gh[pousr]_|github_pat_|sk-|xox[baprs]-|ya29\.|AKIA|eyJ)|^[A-Fa-f0-9]{32,}$/; + +/** + * The top-level argument NAMES, in call order. + * + * Names describe the shape of a call ("it passed `siteUrl` and `body`") + * without exposing what was in it. But a name is caller-controlled too — + * `execute` takes `unknown` args, so `{ "ghp_realtoken": null }` is a + * reachable shape — hence the filter: only identifier-shaped, bounded names + * that do not themselves look like a secret survive. Non-object arguments have + * no names, and neither does an absent one. + */ +export const toolCallArgKeys = (args: unknown): readonly string[] | null => { + if (args == null || typeof args !== "object" || Array.isArray(args)) return null; + const keys = Object.keys(args as Record).filter( + (key) => + key.length <= TOOL_CALL_ARG_KEY_MAX_LENGTH && + PARAMETER_NAME.test(key) && + !CREDENTIAL_SHAPED.test(key), + ); + if (keys.length === 0) return null; + return keys.slice(0, TOOL_CALL_ARG_KEY_LIMIT); +}; + +const decodeArgKeys = (value: unknown): readonly string[] | null => { + if (!Array.isArray(value)) return null; + const keys = value.filter((entry): entry is string => typeof entry === "string"); + return keys.length > 0 ? keys : null; +}; + +const isToolCallOutcome = (value: unknown): value is ToolCallOutcome => + value === "ok" || + value === "fail" || + value === "blocked" || + value === "declined" || + value === "error"; + +export const rowToToolCall = (row: ToolCallLogRow): ToolCall => ({ + id: String(row.id), + owner: row.owner as Owner, + address: String(row.address), + integration: row.integration == null ? null : String(row.integration), + connection: row.connection == null ? null : String(row.connection), + tool: row.tool == null ? null : String(row.tool), + // A row whose outcome cannot be read is still a call that happened; it is + // reported as `error` rather than dropped from the audit. + outcome: isToolCallOutcome(row.outcome) ? row.outcome : "error", + errorCode: row.error_code == null ? null : String(row.error_code), + errorMessage: row.error_message == null ? null : String(row.error_message), + policyAction: row.policy_action == null ? null : String(row.policy_action), + policyPattern: row.policy_pattern == null ? null : String(row.policy_pattern), + durationMs: Number(row.duration_ms ?? 0), + argKeys: decodeArgKeys(row.arg_keys), + createdAt: row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at)), +}); diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index c20018bd9d..75e5afc116 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -151,6 +151,24 @@ export const pausedExecutionAtom = (executionId: string) => timeToLive: "5 seconds", }); +// --------------------------------------------------------------------------- +// Tool call log — the audit trail. Read-only, so there is no mutation atom and +// no optimistic wrapper: rows appear because calls happened. +// --------------------------------------------------------------------------- + +/** + * The most recent calls this owner scope may see. + * + * Short TTL on purpose: this is the page someone opens while an agent is + * running, to watch what it just did. + */ +export const toolCallsAtom = ExecutorApiClient.query("toolCalls", "list", { + // No filters: the page shows the whole recent log and the endpoint applies + // its own default page size. + query: {}, + timeToLive: "5 seconds", +}); + export const artifactsAtom = ExecutorApiClient.query("artifacts", "list", { timeToLive: "30 seconds", reactivityKeys: [ReactivityKey.artifacts], diff --git a/packages/react/src/console-routes.ts b/packages/react/src/console-routes.ts index 4d32b876e4..55a9115663 100644 --- a/packages/react/src/console-routes.ts +++ b/packages/react/src/console-routes.ts @@ -38,6 +38,7 @@ export const CONSOLE_ROUTE_PATHS = [ "/integrations/$namespace", "/integrations/add/$pluginKey", "/policies", + "/activity", "/secrets", "/tools", "/users", @@ -79,6 +80,9 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array = [ { to: "/", label: "Integrations" }, { to: "/secrets", label: "Providers" }, { to: "/policies", label: "Policies" }, + { to: "/activity", label: "Activity" }, { to: "/toolkits", label: "Toolkits" }, { to: "/artifacts", label: "Artifacts" }, ]; diff --git a/packages/react/src/pages/activity.tsx b/packages/react/src/pages/activity.tsx new file mode 100644 index 0000000000..7117d03131 --- /dev/null +++ b/packages/react/src/pages/activity.tsx @@ -0,0 +1,145 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; + +import { toolCallsAtom } from "../api/atoms"; +import { Badge } from "../components/badge"; +import { Button } from "../components/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/empty"; +import { ErrorState } from "../components/error-state"; +import { PageContainer, PageHeader } from "../components/page"; +import { Skeleton } from "../components/skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/table"; +import { useExecutorDocumentTitle } from "../lib/document-title"; + +// --------------------------------------------------------------------------- +// Activity — the tool call log. +// +// One row per call that reached the executor, newest first. The rows that make +// this page worth opening are the ones with no other trace: a call a policy +// blocked, and an approval someone declined. Both end before any request is +// made, so nothing upstream ever saw them. +// --------------------------------------------------------------------------- + +type ToolCallRow = { + readonly id: string; + readonly address: string; + readonly integration: string | null; + readonly tool: string | null; + readonly outcome: "ok" | "fail" | "blocked" | "declined" | "error"; + readonly errorCode: string | null; + readonly errorMessage: string | null; + readonly policyAction: string | null; + readonly durationMs: number; + readonly createdAt: number; +}; + +const OUTCOME_VARIANT = { + ok: "secondary", + fail: "destructive", + blocked: "destructive", + declined: "outline", + error: "destructive", +} as const; + +const OUTCOME_LABEL = { + ok: "ok", + fail: "failed", + blocked: "blocked", + declined: "declined", + error: "error", +} as const; + +const formatDuration = (ms: number): string => + ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; + +const formatWhen = (epochMs: number): string => + new Date(epochMs).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + +/** The upstream code says more than the message; fall back to the message. */ +const detailOf = (call: ToolCallRow): string | null => call.errorCode ?? call.errorMessage ?? null; + +export function ActivityPage() { + useExecutorDocumentTitle("Activity"); + const calls = useAtomValue(toolCallsAtom); + const refresh = useAtomRefresh(toolCallsAtom); + + return ( + + + Refresh + + } + /> + {AsyncResult.match(calls, { + onInitial: () => , + onFailure: () => ( + + ), + onSuccess: (success) => , + })} + + ); +} + +function ActivityTable({ calls }: { readonly calls: readonly ToolCallRow[] }) { + if (calls.length === 0) { + return ( + + + No calls yet + + Once an agent runs a tool through this executor, every call shows up here. + + + + ); + } + + return ( + + + + When + Tool + Outcome + Detail + Duration + + + + {calls.map((call) => ( + + + {formatWhen(call.createdAt)} + + + {call.tool ?? call.address} + {call.integration ? ( + {call.integration} + ) : null} + + + {OUTCOME_LABEL[call.outcome]} + + + {detailOf(call) ?? (call.policyAction ? `policy: ${call.policyAction}` : "—")} + + + {formatDuration(call.durationMs)} + + + ))} + +
+ ); +} diff --git a/packages/react/src/routes/activity.tsx b/packages/react/src/routes/activity.tsx new file mode 100644 index 0000000000..48f26599e2 --- /dev/null +++ b/packages/react/src/routes/activity.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ActivityPage } from "../pages/activity"; + +export const Route = createFileRoute("/{-$orgSlug}/activity")({ + component: () => , +}); diff --git a/packages/react/src/routes/routeTree.gen.ts b/packages/react/src/routes/routeTree.gen.ts index f4934a1989..3b6cb0e966 100644 --- a/packages/react/src/routes/routeTree.gen.ts +++ b/packages/react/src/routes/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as DotToolkitsRouteImport } from './toolkits' import { Route as DotSecretsRouteImport } from './secrets' import { Route as DotPoliciesRouteImport } from './policies' import { Route as DotArtifactsRouteImport } from './artifacts' +import { Route as DotActivityRouteImport } from './activity' import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' @@ -59,6 +60,11 @@ const DotArtifactsRoute = DotArtifactsRouteImport.update({ path: '/{-$orgSlug}/artifacts', getParentRoute: () => rootRouteImport, } as any) +const DotActivityRoute = DotActivityRouteImport.update({ + id: '/{-$orgSlug}/activity', + path: '/{-$orgSlug}/activity', + getParentRoute: () => rootRouteImport, +} as any) const DotToolkitsDottoolkitSlugRoute = DotToolkitsDottoolkitSlugRouteImport.update({ id: '/$toolkitSlug', @@ -102,6 +108,7 @@ const DotIntegrationsDotaddDotpluginKeyRoute = } as any) export interface FileRoutesByFullPath { + '/{-$orgSlug}/activity': typeof DotActivityRoute '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute @@ -118,6 +125,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/plugins/$pluginId/$': typeof DotPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/activity': typeof DotActivityRoute '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute @@ -135,6 +143,7 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/activity': typeof DotActivityRoute '/{-$orgSlug}/artifacts': typeof DotArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotPoliciesRoute '/{-$orgSlug}/secrets': typeof DotSecretsRoute @@ -153,6 +162,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -169,6 +179,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -185,6 +196,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/activity' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' @@ -202,6 +214,7 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotActivityRoute: typeof DotActivityRoute DotArtifactsRoute: typeof DotArtifactsRouteWithChildren DotPoliciesRoute: typeof DotPoliciesRoute DotSecretsRoute: typeof DotSecretsRoute @@ -267,6 +280,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotArtifactsRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/activity': { + id: '/{-$orgSlug}/activity' + path: '/{-$orgSlug}/activity' + fullPath: '/{-$orgSlug}/activity' + preLoaderRoute: typeof DotActivityRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -344,6 +364,7 @@ const DotToolkitsRouteWithChildren = DotToolkitsRoute._addFileChildren( ) const rootRouteChildren: RootRouteChildren = { + DotActivityRoute: DotActivityRoute, DotArtifactsRoute: DotArtifactsRouteWithChildren, DotPoliciesRoute: DotPoliciesRoute, DotSecretsRoute: DotSecretsRoute,