From b26ef844f04a5e35e9d59b88583979cdf005a14a Mon Sep 17 00:00:00 2001 From: Michiel de Gooijer Date: Fri, 7 Aug 2026 19:24:22 +0700 Subject: [PATCH 1/4] Record every tool call in an audit log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — and the analytics catalog is anonymous by construction, so it deliberately drops exactly those fields. Two questions were therefore unanswerable after the fact: what did this agent touch, and what did my policies actually stop. `execute` is the one place every call passes through, whatever the plugin kind and whatever the host, so the row is written there, from an `onExit` wrapper that sees every way a call can end: - ok / fail — reached the upstream. `fail` is a tool's own error result, which rides the SUCCESS channel by design and would otherwise be recorded as a healthy call. - blocked / declined — never left the gateway. A policy stopped it, or a human refused the approval. These leave no other trace anywhere: they end before any request is made. - error — the tool or connection did not exist, the plugin failed to load, the transport broke. Arguments and results are never stored: an argument can be a credential. The row keeps the top-level argument NAMES, which is what an audit needs without the table becoming a place secrets accumulate. Writing a row can never change the outcome of the call it describes — a failed write is logged and swallowed, because an audit trail that can take the gateway down with it is worse than one with a gap in it. Readable three ways: `executor.toolCalls.list()`, `GET /api/tool-calls` (filter by integration, connection, outcome, time), and an Activity page in the console. Read-only by construction — a log a caller can edit is not evidence, so there is no write or delete endpoint. Co-Authored-By: Claude Opus 5 --- .changeset/tool-call-log.md | 13 + apps/cloud/drizzle/0016_nosy_expediter.sql | 21 + apps/cloud/drizzle/meta/0016_snapshot.json | 1635 ++++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 7 + apps/cloud/src/db/executor-schema.ts | 29 + apps/cloud/src/routeTree.gen.ts | 27 + apps/host-cloudflare/web/routeTree.gen.ts | 23 + apps/host-selfhost/web/routeTree.gen.ts | 23 + packages/core/api/src/account/org-slug.ts | 1 + packages/core/api/src/api.ts | 2 + packages/core/api/src/handlers/index.ts | 3 + packages/core/api/src/handlers/tool-calls.ts | 43 + packages/core/api/src/server.ts | 1 + packages/core/api/src/tool-calls/api.ts | 65 + packages/core/sdk/src/core-schema.ts | 76 + packages/core/sdk/src/executor.ts | 173 +- packages/core/sdk/src/index.ts | 21 + packages/core/sdk/src/tool-call-log.test.ts | 334 ++++ packages/core/sdk/src/tool-call-log.ts | 249 +++ packages/react/src/api/atoms.tsx | 18 + packages/react/src/console-routes.ts | 4 + packages/react/src/multiplayer/shell.tsx | 1 + packages/react/src/pages/activity.tsx | 145 ++ packages/react/src/routes/activity.tsx | 7 + packages/react/src/routes/routeTree.gen.ts | 21 + 25 files changed, 2941 insertions(+), 1 deletion(-) create mode 100644 .changeset/tool-call-log.md create mode 100644 apps/cloud/drizzle/0016_nosy_expediter.sql create mode 100644 apps/cloud/drizzle/meta/0016_snapshot.json create mode 100644 packages/core/api/src/handlers/tool-calls.ts create mode 100644 packages/core/api/src/tool-calls/api.ts create mode 100644 packages/core/sdk/src/tool-call-log.test.ts create mode 100644 packages/core/sdk/src/tool-call-log.ts create mode 100644 packages/react/src/pages/activity.tsx create mode 100644 packages/react/src/routes/activity.tsx 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, From e98655f9b219efce9346bf0b3b1f83d962b40872 Mon Sep 17 00:00:00 2001 From: Michiel de Gooijer Date: Mon, 17 Aug 2026 10:32:31 +0700 Subject: [PATCH 2/4] Page the Activity list instead of rendering it whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A day of agent traffic made the page one long scroll. Same pattern as Admin · Users: 25 rows per page, one extra row fetched to know whether a next page exists (splitPage), Previous/Next with the mono page label. `offset` runs through all three layers — the executor list, the HTTP query (bounded, like every numeric input on this API), and an Atom.family keyed on the offset so paging back is instant while the front page keeps its 5s TTL. The log is append-only at the top, so a page can shift while browsing; that is fine for eyeballing, and programmatic sweeps use `since`. Co-Authored-By: Claude Opus 5 --- packages/core/api/src/handlers/tool-calls.ts | 1 + packages/core/api/src/tool-calls/api.ts | 5 ++ packages/core/sdk/src/executor.ts | 1 + packages/core/sdk/src/tool-call-log.test.ts | 8 +++ packages/core/sdk/src/tool-call-log.ts | 4 ++ packages/react/src/api/atoms.tsx | 21 ++++--- packages/react/src/pages/activity.tsx | 64 +++++++++++++++++--- 7 files changed, 89 insertions(+), 15 deletions(-) diff --git a/packages/core/api/src/handlers/tool-calls.ts b/packages/core/api/src/handlers/tool-calls.ts index 9f46049709..548612951e 100644 --- a/packages/core/api/src/handlers/tool-calls.ts +++ b/packages/core/api/src/handlers/tool-calls.ts @@ -35,6 +35,7 @@ export const ToolCallsHandlers = HttpApiBuilder.group(ExecutorApi, "toolCalls", // Epoch ms on the wire; the executor filters on a Date. since: query.since === undefined ? undefined : new Date(query.since), limit: query.limit, + offset: query.offset, }); return calls.map(toResponse); }), diff --git a/packages/core/api/src/tool-calls/api.ts b/packages/core/api/src/tool-calls/api.ts index 38f391c988..9ab6548ee6 100644 --- a/packages/core/api/src/tool-calls/api.ts +++ b/packages/core/api/src/tool-calls/api.ts @@ -54,6 +54,11 @@ const ListToolCallsQuery = Schema.Struct({ Schema.isBetween({ minimum: 1, maximum: TOOL_CALL_LIST_MAX_LIMIT }), ), ), + // Paging. Bounded like every other numeric input: an unbounded offset is a + // cheap way to make the database walk the whole partition. + offset: Schema.optional( + Schema.FiniteFromString.check(Schema.isBetween({ minimum: 0, maximum: 1_000_000 })), + ), }); export const ToolCallsApi = HttpApiGroup.make("toolCalls").add( diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 3553f5199c..9560913960 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -4176,6 +4176,7 @@ export const createExecutor = 0 ? { offset: Math.floor(input.offset) } : {}), }) .pipe(Effect.map((rows) => rows.map(rowToToolCall))); diff --git a/packages/core/sdk/src/tool-call-log.test.ts b/packages/core/sdk/src/tool-call-log.test.ts index f5e6736e82..d9b331c752 100644 --- a/packages/core/sdk/src/tool-call-log.test.ts +++ b/packages/core/sdk/src/tool-call-log.test.ts @@ -329,6 +329,14 @@ describe("executor.toolCalls", () => { const limited = yield* executor.toolCalls.list({ limit: 2 }); expect(limited).toHaveLength(2); + + // Paging: offset skips from the top of the same newest-first order, and + // the pages tile the list without overlap. + const firstPage = yield* executor.toolCalls.list({ limit: 2 }); + const secondPage = yield* executor.toolCalls.list({ limit: 2, offset: 2 }); + expect(secondPage).toHaveLength(1); + const seen = new Set([...firstPage, ...secondPage].map((c) => c.id)); + expect(seen.size).toBe(3); }), ); }); diff --git a/packages/core/sdk/src/tool-call-log.ts b/packages/core/sdk/src/tool-call-log.ts index 401b6a3508..f5e80f34ce 100644 --- a/packages/core/sdk/src/tool-call-log.ts +++ b/packages/core/sdk/src/tool-call-log.ts @@ -55,6 +55,10 @@ export interface ListToolCallsInput { /** Only calls at or after this instant. */ readonly since?: Date; readonly limit?: number; + /** Rows to skip, for paging. The log is append-only at the top, so a page + * can shift while being browsed; fine for eyeballing, use `since` for + * programmatic sweeps. */ + readonly offset?: number; } export interface PruneToolCallsInput { diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 75e5afc116..6cc3cc9578 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -156,18 +156,23 @@ export const pausedExecutionAtom = (executionId: string) => // no optimistic wrapper: rows appear because calls happened. // --------------------------------------------------------------------------- +/** The Activity page size. One extra row is fetched to know whether a next + * page exists — same trick as `ADMIN_USERS_PAGE_SIZE`. */ +export const TOOL_CALLS_PAGE_SIZE = 25; + /** - * The most recent calls this owner scope may see. + * One page of the tool call log, newest first. * * Short TTL on purpose: this is the page someone opens while an agent is - * running, to watch what it just did. + * running, to watch what it just did. `Atom.family` keys on the offset so + * paging back is instant while the front page stays fresh. */ -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 toolCallsPageAtom = Atom.family((offset: number) => + ExecutorApiClient.query("toolCalls", "list", { + query: { limit: TOOL_CALLS_PAGE_SIZE + 1, ...(offset > 0 ? { offset } : {}) }, + timeToLive: "5 seconds", + }), +); export const artifactsAtom = ExecutorApiClient.query("artifacts", "list", { timeToLive: "30 seconds", diff --git a/packages/react/src/pages/activity.tsx b/packages/react/src/pages/activity.tsx index 7117d03131..d141ed9996 100644 --- a/packages/react/src/pages/activity.tsx +++ b/packages/react/src/pages/activity.tsx @@ -1,7 +1,8 @@ +import { useState } from "react"; import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { toolCallsAtom } from "../api/atoms"; +import { TOOL_CALLS_PAGE_SIZE, toolCallsPageAtom } from "../api/atoms"; import { Badge } from "../components/badge"; import { Button } from "../components/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/empty"; @@ -9,6 +10,7 @@ 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 { pageNumber, splitPage } from "../lib/admin-users-display"; import { useExecutorDocumentTitle } from "../lib/document-title"; // --------------------------------------------------------------------------- @@ -66,8 +68,9 @@ const detailOf = (call: ToolCallRow): string | null => call.errorCode ?? call.er export function ActivityPage() { useExecutorDocumentTitle("Activity"); - const calls = useAtomValue(toolCallsAtom); - const refresh = useAtomRefresh(toolCallsAtom); + const [offset, setOffset] = useState(0); + const calls = useAtomValue(toolCallsPageAtom(offset)); + const refresh = useAtomRefresh(toolCallsPageAtom(offset)); return ( @@ -85,20 +88,67 @@ export function ActivityPage() { onFailure: () => ( ), - onSuccess: (success) => , + onSuccess: (success) => { + // One row past the page size answers "is there a next page" without + // a count query — same trick as Admin · Users. + const { rows, hasNext } = splitPage( + success.value as readonly ToolCallRow[], + TOOL_CALLS_PAGE_SIZE, + ); + return ( + <> + + {(hasNext || offset > 0) && ( +
+ + Page {pageNumber(offset, TOOL_CALLS_PAGE_SIZE)} + +
+ + +
+
+ )} + + ); + }, })}
); } -function ActivityTable({ calls }: { readonly calls: readonly ToolCallRow[] }) { +function ActivityTable({ + calls, + onFirstPage, +}: { + readonly calls: readonly ToolCallRow[]; + readonly onFirstPage: boolean; +}) { if (calls.length === 0) { + // Past the end only happens when the last row of a page is pruned while + // browsing; the pager above still offers Previous to walk back. return ( - No calls yet + {onFirstPage ? "No calls yet" : "No calls on this page"} - Once an agent runs a tool through this executor, every call shows up here. + {onFirstPage + ? "Once an agent runs a tool through this executor, every call shows up here." + : "Go back a page to see recorded calls."} From accee74080f1bc5c6b54b7af77541c0cbd7558f2 Mon Sep 17 00:00:00 2001 From: Michiel de Gooijer Date: Mon, 17 Aug 2026 12:14:26 +0700 Subject: [PATCH 3/4] Filter and search the Activity log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outcome filter as FilterTabs (All / Ok / Failed / Blocked / Declined / Error) and a search box over the tool address — the one free-text field a row has that is safe to search, because this codebase wrote it rather than an upstream. Both reset to page 1: a new filter is a new list. `search` runs through the same three layers as the other filters: a DB-level `contains` on the executor list (LIKE wildcards in the query are harmless — the match never leaves the caller's own partition), a bounded string on the HTTP query, and the Atom.family key, which now carries `offset|outcome|search`. The input defers the query, not the keystroke. Co-Authored-By: Claude Opus 5 --- packages/core/api/src/handlers/tool-calls.ts | 1 + packages/core/api/src/tool-calls/api.ts | 3 + packages/core/sdk/src/executor.ts | 3 + packages/core/sdk/src/tool-call-log.test.ts | 13 ++++ packages/core/sdk/src/tool-call-log.ts | 3 + packages/react/src/api/atoms.tsx | 37 +++++++-- packages/react/src/pages/activity.tsx | 82 ++++++++++++++++---- 7 files changed, 122 insertions(+), 20 deletions(-) diff --git a/packages/core/api/src/handlers/tool-calls.ts b/packages/core/api/src/handlers/tool-calls.ts index 548612951e..4c058e7b45 100644 --- a/packages/core/api/src/handlers/tool-calls.ts +++ b/packages/core/api/src/handlers/tool-calls.ts @@ -36,6 +36,7 @@ export const ToolCallsHandlers = HttpApiBuilder.group(ExecutorApi, "toolCalls", since: query.since === undefined ? undefined : new Date(query.since), limit: query.limit, offset: query.offset, + search: query.search, }); return calls.map(toResponse); }), diff --git a/packages/core/api/src/tool-calls/api.ts b/packages/core/api/src/tool-calls/api.ts index 9ab6548ee6..0a38b62790 100644 --- a/packages/core/api/src/tool-calls/api.ts +++ b/packages/core/api/src/tool-calls/api.ts @@ -59,6 +59,9 @@ const ListToolCallsQuery = Schema.Struct({ offset: Schema.optional( Schema.FiniteFromString.check(Schema.isBetween({ minimum: 0, maximum: 1_000_000 })), ), + /** Substring match on the address. Bounded: nobody types 200 characters of + * tool address, and an unbounded pattern is free load on the database. */ + search: Schema.optional(Schema.String.check(Schema.isMaxLength(200))), }); export const ToolCallsApi = HttpApiGroup.make("toolCalls").add( diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 9560913960..b4f0165a31 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -4196,6 +4196,9 @@ export const createExecutor = { const clauses: readonly ((b: AnyCb) => Condition)[] = [ ...(input?.integration ? [(b: AnyCb) => b("integration", "=", input.integration!)] : []), + // `%`/`_` in the query are LIKE wildcards; harmless here — the match + // never leaves the caller's own owner partition. + ...(input?.search ? [(b: AnyCb) => b("address", "contains", input.search!)] : []), ...(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!)] : []), diff --git a/packages/core/sdk/src/tool-call-log.test.ts b/packages/core/sdk/src/tool-call-log.test.ts index d9b331c752..15aaf4c301 100644 --- a/packages/core/sdk/src/tool-call-log.test.ts +++ b/packages/core/sdk/src/tool-call-log.test.ts @@ -337,6 +337,19 @@ describe("executor.toolCalls", () => { expect(secondPage).toHaveLength(1); const seen = new Set([...firstPage, ...secondPage].map((c) => c.id)); expect(seen.size).toBe(3); + + // Search: substring on the address, composable with the other filters. + const byAddress = yield* executor.toolCalls.list({ search: "missing" }); + expect(byAddress).toHaveLength(1); + expect(byAddress[0]?.tool).toBe("missing"); + const byPartial = yield* executor.toolCalls.list({ search: "github.org" }); + expect(byPartial).toHaveLength(3); + const searchAndOutcome = yield* executor.toolCalls.list({ + search: "github.org", + outcome: "ok", + }); + expect(searchAndOutcome).toHaveLength(2); + expect(yield* executor.toolCalls.list({ search: "no-such-address" })).toEqual([]); }), ); }); diff --git a/packages/core/sdk/src/tool-call-log.ts b/packages/core/sdk/src/tool-call-log.ts index f5e80f34ce..e92ddddf51 100644 --- a/packages/core/sdk/src/tool-call-log.ts +++ b/packages/core/sdk/src/tool-call-log.ts @@ -59,6 +59,9 @@ export interface ListToolCallsInput { * can shift while being browsed; fine for eyeballing, use `since` for * programmatic sweeps. */ readonly offset?: number; + /** Substring match on the address as called — the one free-text field a row + * has that is safe to search: this file wrote it, not an upstream. */ + readonly search?: string; } export interface PruneToolCallsInput { diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 6cc3cc9578..48e254e196 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -160,19 +160,42 @@ export const pausedExecutionAtom = (executionId: string) => * page exists — same trick as `ADMIN_USERS_PAGE_SIZE`. */ export const TOOL_CALLS_PAGE_SIZE = 25; +export type ToolCallOutcomeFilter = "all" | "ok" | "fail" | "blocked" | "declined" | "error"; + +export interface ToolCallsPageKey { + readonly offset: number; + readonly outcome: ToolCallOutcomeFilter; + readonly search: string; +} + /** * One page of the tool call log, newest first. * * Short TTL on purpose: this is the page someone opens while an agent is - * running, to watch what it just did. `Atom.family` keys on the offset so - * paging back is instant while the front page stays fresh. + * running, to watch what it just did. `Atom.family` needs a primitive key, so + * the filter set travels as `offset|outcome|search` — paging back within the + * same filters is then instant while the front page stays fresh. Split on the + * first two pipes only: the search text is free-form and may contain one. */ -export const toolCallsPageAtom = Atom.family((offset: number) => - ExecutorApiClient.query("toolCalls", "list", { - query: { limit: TOOL_CALLS_PAGE_SIZE + 1, ...(offset > 0 ? { offset } : {}) }, +export const toolCallsPageAtom = Atom.family((key: string) => { + const firstPipe = key.indexOf("|"); + const secondPipe = key.indexOf("|", firstPipe + 1); + const offset = Number(key.slice(0, firstPipe)) || 0; + const outcome = key.slice(firstPipe + 1, secondPipe) as ToolCallOutcomeFilter; + const search = key.slice(secondPipe + 1); + return ExecutorApiClient.query("toolCalls", "list", { + query: { + limit: TOOL_CALLS_PAGE_SIZE + 1, + ...(offset > 0 ? { offset } : {}), + ...(outcome !== "all" ? { outcome } : {}), + ...(search !== "" ? { search } : {}), + }, timeToLive: "5 seconds", - }), -); + }); +}); + +export const toolCallsPageKey = (key: ToolCallsPageKey): string => + `${key.offset}|${key.outcome}|${key.search}`; export const artifactsAtom = ExecutorApiClient.query("artifacts", "list", { timeToLive: "30 seconds", diff --git a/packages/react/src/pages/activity.tsx b/packages/react/src/pages/activity.tsx index d141ed9996..c5b6817677 100644 --- a/packages/react/src/pages/activity.tsx +++ b/packages/react/src/pages/activity.tsx @@ -1,12 +1,19 @@ -import { useState } from "react"; +import { useDeferredValue, useState } from "react"; import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { TOOL_CALLS_PAGE_SIZE, toolCallsPageAtom } from "../api/atoms"; +import { + TOOL_CALLS_PAGE_SIZE, + toolCallsPageAtom, + toolCallsPageKey, + type ToolCallOutcomeFilter, +} 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 { FilterTabs } from "../components/filter-tabs"; +import { Input } from "../components/input"; import { PageContainer, PageHeader } from "../components/page"; import { Skeleton } from "../components/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/table"; @@ -66,11 +73,34 @@ const formatWhen = (epochMs: number): string => /** The upstream code says more than the message; fall back to the message. */ const detailOf = (call: ToolCallRow): string | null => call.errorCode ?? call.errorMessage ?? null; +const OUTCOME_TABS: readonly { label: string; value: ToolCallOutcomeFilter }[] = [ + { label: "All", value: "all" }, + { label: "Ok", value: "ok" }, + { label: "Failed", value: "fail" }, + { label: "Blocked", value: "blocked" }, + { label: "Declined", value: "declined" }, + { label: "Error", value: "error" }, +]; + export function ActivityPage() { useExecutorDocumentTitle("Activity"); const [offset, setOffset] = useState(0); - const calls = useAtomValue(toolCallsPageAtom(offset)); - const refresh = useAtomRefresh(toolCallsPageAtom(offset)); + const [outcome, setOutcome] = useState("all"); + const [search, setSearch] = useState(""); + // Defer the query, not the keystroke: the input stays snappy while the + // request only fires for the settled value. + const deferredSearch = useDeferredValue(search.trim()); + const key = toolCallsPageKey({ offset, outcome, search: deferredSearch }); + const calls = useAtomValue(toolCallsPageAtom(key)); + const refresh = useAtomRefresh(toolCallsPageAtom(key)); + + const setFilter = (next: { outcome?: ToolCallOutcomeFilter; search?: string }) => { + // A new filter is a new list; page 1 is the only offset that means + // anything in it. + setOffset(0); + if (next.outcome !== undefined) setOutcome(next.outcome); + if (next.search !== undefined) setSearch(next.search); + }; return ( @@ -83,6 +113,20 @@ export function ActivityPage() { } /> +
+ setFilter({ outcome: value })} + /> + setFilter({ search: (e.target as HTMLInputElement).value })} + placeholder="Search by tool address…" + className="w-full sm:w-64" + /> +
{AsyncResult.match(calls, { onInitial: () => , onFailure: () => ( @@ -97,7 +141,11 @@ export function ActivityPage() { ); return ( <> - + {(hasNext || offset > 0) && (
@@ -134,22 +182,30 @@ export function ActivityPage() { function ActivityTable({ calls, onFirstPage, + filtered, }: { readonly calls: readonly ToolCallRow[]; readonly onFirstPage: boolean; + readonly filtered: boolean; }) { if (calls.length === 0) { - // Past the end only happens when the last row of a page is pruned while - // browsing; the pager above still offers Previous to walk back. + // Three empty states, three different truths: nothing recorded yet, a + // filter that matches nothing, or a page past the end after pruning. + const title = filtered + ? "No matching calls" + : onFirstPage + ? "No calls yet" + : "No calls on this page"; + const description = filtered + ? "No recorded call matches these filters." + : onFirstPage + ? "Once an agent runs a tool through this executor, every call shows up here." + : "Go back a page to see recorded calls."; return ( - {onFirstPage ? "No calls yet" : "No calls on this page"} - - {onFirstPage - ? "Once an agent runs a tool through this executor, every call shows up here." - : "Go back a page to see recorded calls."} - + {title} + {description} ); From 5ce27b6ebfd45b59ccea1e729260460786596c55 Mon Sep 17 00:00:00 2001 From: Michiel de Gooijer Date: Mon, 17 Aug 2026 12:29:35 +0700 Subject: [PATCH 4/4] Integration dropdown on the Activity filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Select between the outcome tabs and the search box, listing the tenant's own integration catalog with the same brand marks the Integrations page draws (integrationPresetIconUrl → IntegrationFavicon), so gsc and github tell apart at a glance. Wired to the `integration` filter the API had from the start; "" means all, carried as a sentinel because Select cannot hold an empty value. Like every other filter change it resets to page 1. Co-Authored-By: Claude Opus 5 --- packages/react/src/api/atoms.tsx | 11 +++- packages/react/src/pages/activity.tsx | 94 ++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 48e254e196..0861c7dd65 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -165,6 +165,8 @@ export type ToolCallOutcomeFilter = "all" | "ok" | "fail" | "blocked" | "decline export interface ToolCallsPageKey { readonly offset: number; readonly outcome: ToolCallOutcomeFilter; + /** Integration slug, or "" for all. */ + readonly integration: string; readonly search: string; } @@ -175,19 +177,22 @@ export interface ToolCallsPageKey { * running, to watch what it just did. `Atom.family` needs a primitive key, so * the filter set travels as `offset|outcome|search` — paging back within the * same filters is then instant while the front page stays fresh. Split on the - * first two pipes only: the search text is free-form and may contain one. + * first three pipes only: the search text is free-form and may contain one. */ export const toolCallsPageAtom = Atom.family((key: string) => { const firstPipe = key.indexOf("|"); const secondPipe = key.indexOf("|", firstPipe + 1); + const thirdPipe = key.indexOf("|", secondPipe + 1); const offset = Number(key.slice(0, firstPipe)) || 0; const outcome = key.slice(firstPipe + 1, secondPipe) as ToolCallOutcomeFilter; - const search = key.slice(secondPipe + 1); + const integration = key.slice(secondPipe + 1, thirdPipe); + const search = key.slice(thirdPipe + 1); return ExecutorApiClient.query("toolCalls", "list", { query: { limit: TOOL_CALLS_PAGE_SIZE + 1, ...(offset > 0 ? { offset } : {}), ...(outcome !== "all" ? { outcome } : {}), + ...(integration !== "" ? { integration } : {}), ...(search !== "" ? { search } : {}), }, timeToLive: "5 seconds", @@ -195,7 +200,7 @@ export const toolCallsPageAtom = Atom.family((key: string) => { }); export const toolCallsPageKey = (key: ToolCallsPageKey): string => - `${key.offset}|${key.outcome}|${key.search}`; + `${key.offset}|${key.outcome}|${key.integration}|${key.search}`; export const artifactsAtom = ExecutorApiClient.query("artifacts", "list", { timeToLive: "30 seconds", diff --git a/packages/react/src/pages/activity.tsx b/packages/react/src/pages/activity.tsx index c5b6817677..0848e64ec6 100644 --- a/packages/react/src/pages/activity.tsx +++ b/packages/react/src/pages/activity.tsx @@ -1,8 +1,12 @@ import { useDeferredValue, useState } from "react"; import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Option from "effect/Option"; +import { useIntegrationPlugins } from "@executor-js/sdk/client"; +import type { Integration } from "@executor-js/sdk"; import { + integrationsOptimisticAtom, TOOL_CALLS_PAGE_SIZE, toolCallsPageAtom, toolCallsPageKey, @@ -14,6 +18,14 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ import { ErrorState } from "../components/error-state"; import { FilterTabs } from "../components/filter-tabs"; import { Input } from "../components/input"; +import { IntegrationFavicon, integrationPresetIconUrl } from "../components/integration-favicon"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../components/select"; import { PageContainer, PageHeader } from "../components/page"; import { Skeleton } from "../components/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/table"; @@ -82,23 +94,57 @@ const OUTCOME_TABS: readonly { label: string; value: ToolCallOutcomeFilter }[] = { label: "Error", value: "error" }, ]; +/** The catalog as dropdown entries: slug, display name and brand mark. The + * same favicon pipeline the Integrations page uses, so the marks match. */ +const useIntegrationOptions = () => { + const catalog = useAtomValue(integrationsOptimisticAtom); + const integrationPlugins = useIntegrationPlugins(); + const integrations = Option.getOrElse( + AsyncResult.value(catalog), + (): readonly Integration[] => [], + ); + return integrations.map((row) => { + const slug = String(row.slug); + const name = row.name || slug; + return { + slug, + name, + icon: integrationPresetIconUrl( + { id: slug, kind: row.kind, name, url: row.displayUrl }, + integrationPlugins, + ), + url: row.displayUrl, + }; + }); +}; + +/** Sentinel for "no integration filter" — Select cannot carry an empty value. */ +const ALL_INTEGRATIONS = "__all__"; + export function ActivityPage() { useExecutorDocumentTitle("Activity"); const [offset, setOffset] = useState(0); const [outcome, setOutcome] = useState("all"); + const [integration, setIntegration] = useState(""); const [search, setSearch] = useState(""); + const integrationOptions = useIntegrationOptions(); // Defer the query, not the keystroke: the input stays snappy while the // request only fires for the settled value. const deferredSearch = useDeferredValue(search.trim()); - const key = toolCallsPageKey({ offset, outcome, search: deferredSearch }); + const key = toolCallsPageKey({ offset, outcome, integration, search: deferredSearch }); const calls = useAtomValue(toolCallsPageAtom(key)); const refresh = useAtomRefresh(toolCallsPageAtom(key)); - const setFilter = (next: { outcome?: ToolCallOutcomeFilter; search?: string }) => { + const setFilter = (next: { + outcome?: ToolCallOutcomeFilter; + integration?: string; + search?: string; + }) => { // A new filter is a new list; page 1 is the only offset that means // anything in it. setOffset(0); if (next.outcome !== undefined) setOutcome(next.outcome); + if (next.integration !== undefined) setIntegration(next.integration); if (next.search !== undefined) setSearch(next.search); }; @@ -119,13 +165,41 @@ export function ActivityPage() { value={outcome} onChange={(value) => setFilter({ outcome: value })} /> - setFilter({ search: (e.target as HTMLInputElement).value })} - placeholder="Search by tool address…" - className="w-full sm:w-64" - /> +
+ + setFilter({ search: (e.target as HTMLInputElement).value })} + placeholder="Search by tool address…" + className="w-full sm:w-64" + /> +
{AsyncResult.match(calls, { onInitial: () => , @@ -144,7 +218,7 @@ export function ActivityPage() { {(hasNext || offset > 0) && (