Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/tools-list-scoped-catalog-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@executor-js/sdk": patch
"@executor-js/execution": patch
---

**Fix: a filtered tools read no longer waits on unrelated integrations' catalog refreshes**

`tools.list` refreshes stale tool catalogs before it answers, and each refresh is a live upstream handshake. That scan ignored the read's own filter, so `GET /api/tools?integration=railway` re-listed every stale connection in the workspace — thirteen sequential handshakes for integrations the caller had not asked for, none of them railway. The scan also applied the remote-catalog freshness TTL globally, so any connection older than the TTL was fetched in full and then discarded by a per-row check.

The refresh now scopes to the same `integration`/`owner`/`connection` filter the read uses, narrows the TTL and config-revision triggers to the integrations they can actually fire for, projects only the four columns it reads, and runs the remaining refreshes concurrently instead of one at a time. A plugin defect raised during a refresh can no longer fail the read. Tool search's empty-query enumeration pushes its namespace into the read for the same reason.
19 changes: 19 additions & 0 deletions .claude/skills/prod-telemetry/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ join the same traces via traceparent).
`mcp.tool.integration`, same outcome attrs.
- `plugin.openapi.invoke` — `plugin.openapi.method` / `path_template` /
`base_url`, and since PR #992 `http.status_code`.
- `executor.tools.list` — `executor.tools.filter.integration` / `.owner` /
`.connection` (present only when the read was filtered),
`executor.tools.result_count`, and the catalog-refresh counters
`executor.tools.sync.candidates` (rows the stale scan returned) /
`executor.tools.sync.synced` / `executor.tools.sync.failed`. A slow tools
read is almost always `candidates` > 0: subtract the child span durations to
confirm. A connection stuck permanently stale shows up as `failed` > 0 on
every read for its scope — the refresh is best-effort and never fails the
read, so this counter and the `executor tool catalog refresh failed` warning
are the only signals it emits.
- `executor.tools.sync` (child of the above, one per refreshed connection;
older spans carry the previous name `executor.tools.sync_stale` and no
trigger/outcome attrs) — `executor.integration`,
`executor.connection`, `executor.tools.sync.trigger`
(`stale_marked`/`config_revised`/`expired`) and
`executor.tools.sync.outcome` (`ok`/`fail`). The matching warning log carries
`integration`, `connection`, `trigger` and `errorTags` only: a refresh runs
on a live credential, so no cause or upstream message is logged and Axiom
will not have one to search.
- `mcp.request` (outer) — `mcp.auth.organization_id`,
`mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…),
MCP client fingerprint (`mcp.client.name`…).
Expand Down
45 changes: 31 additions & 14 deletions packages/core/execution/src/tool-invoker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
authToolFailure,
isUserActionableError,
isToolResult,
IntegrationSlug,
ToolResult,
ToolAddress,
parseToolAddress,
Expand Down Expand Up @@ -665,13 +666,17 @@ export const searchTools = Effect.fn("executor.tools.search")(function* (
});

const emptyQuery = normalizeSearchText(query).length === 0;
const hasNamespace =
options?.namespace !== undefined && normalizeSearchText(options.namespace).length > 0;
// The exact integration slug an empty-query enumeration reads under; null
// when the caller named no usable namespace.
const scope =
options?.namespace !== undefined && normalizeSearchText(options.namespace).length > 0
? IntegrationSlug.make(options.namespace.trim())
: null;

// An empty query with no namespace stays empty: it carries neither a
// ranking signal nor a scope, and listing the whole workspace "by default"
// is exactly the arbitrary dump the ranked search refuses to be.
if (emptyQuery && !hasNamespace) {
if (emptyQuery && scope === null) {
return {
items: [],
total: 0,
Expand All @@ -680,15 +685,26 @@ export const searchTools = Effect.fn("executor.tools.search")(function* (
} satisfies PagedResult<ToolDiscoveryResult>;
}

const all = yield* executor.tools.list({ includeAnnotations: false }).pipe(
Effect.mapError(
(cause) =>
new ExecutionToolError({
message: "Failed to list tools for search",
cause,
}),
),
);
// Enumeration's scope is an exact integration slug (see below), so it is the
// read's filter, not a post-read predicate: pushing it down lets the list
// narrow its own catalog refresh to that one integration instead of
// re-listing every stale connection in the workspace. Ranked search is
// deliberately NOT scoped here — `matchesNamespace` is token-prefix, so a
// scoped read would drop the prefix-sibling integrations it means to match.
const all = yield* executor.tools
.list({
includeAnnotations: false,
...(emptyQuery && scope !== null ? { integration: scope } : {}),
})
.pipe(
Effect.mapError(
(cause) =>
new ExecutionToolError({
message: "Failed to list tools for search",
cause,
}),
),
);
const searchable = all.map(toSearchableTool);

// An empty query WITH a namespace is enumeration, not search: there is no
Expand All @@ -698,10 +714,11 @@ export const searchTools = Effect.fn("executor.tools.search")(function* (
// sweep in prefix-sibling integrations (namespace "google" matching
// google_gmail and google_sheets), which would silently break the census
// guarantee: `total` here must reconcile against
// `executor.integrations.list`'s per-integration toolCount.
// `executor.integrations.list`'s per-integration toolCount. That exact match
// is now the `integration` filter on the read above, so `searchable` is
// already the namespace's catalog and re-filtering it here would be a no-op.
const ranked: readonly ToolDiscoveryResult[] = emptyQuery
? searchable
.filter((tool) => tool.integration === options?.namespace?.trim())
.sort((left, right) => left.path.localeCompare(right.path))
.map((tool) => ({
path: tool.path,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/sdk/src/core-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,16 @@ export const TOOL_INVOCATION_COLUMNS = [
"created_at",
"updated_at",
] as const satisfies readonly (keyof ToolRow)[];
/** The connection columns the tools read's catalog-refresh scan projects: the
* address `produceConnectionTools` needs plus the stamp its trigger check
* reads. Credentials, OAuth state and health JSON stay unread — the scan runs
* on every `tools.list`, and in steady state it must match no rows at all. */
export const CONNECTION_CATALOG_SCAN_COLUMNS = [
"owner",
"integration",
"name",
"tools_synced_at",
] as const satisfies readonly (keyof ConnectionRow)[];
export type DefinitionRow = FumaRow<CoreSchema["definition"]>;
export type ToolPolicyRow = FumaRow<CoreSchema["tool_policy"]>;
export type ArtifactRow = FumaRow<CoreSchema["artifact"]>;
Expand Down
Loading
Loading