From baac0b91fc034e76ccc0c515ba54ce56b6623791 Mon Sep 17 00:00:00 2001 From: David Rossiter Date: Tue, 28 Jul 2026 22:09:39 +0100 Subject: [PATCH 1/2] fix graphql introspection health diagnostics --- .changeset/quiet-graphs-report.md | 5 + .../graphql-introspection-health.test.ts | 134 +++++++++ packages/core/sdk/src/core-tools.ts | 22 +- packages/core/sdk/src/executor.test.ts | 74 ++++- packages/plugins/graphql/src/sdk/errors.ts | 12 + .../plugins/graphql/src/sdk/introspect.ts | 15 +- .../plugins/graphql/src/sdk/plugin.test.ts | 162 +++++++++++ packages/plugins/graphql/src/sdk/plugin.ts | 260 ++++++++++++++++-- .../react/src/components/accounts-section.tsx | 13 +- .../src/components/add-account-modal.tsx | 72 +++-- packages/react/src/components/tool-detail.tsx | 23 +- packages/react/src/components/tool-tree.tsx | 6 +- .../react/src/pages/integration-detail.tsx | 84 +++++- 13 files changed, 815 insertions(+), 67 deletions(-) create mode 100644 .changeset/quiet-graphs-report.md create mode 100644 e2e/scenarios/graphql-introspection-health.test.ts diff --git a/.changeset/quiet-graphs-report.md b/.changeset/quiet-graphs-report.md new file mode 100644 index 000000000..6fdd329e6 --- /dev/null +++ b/.changeset/quiet-graphs-report.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +**Fix: GraphQL connections now reject credentials when schema introspection fails and show actionable tool sync diagnostics** diff --git a/e2e/scenarios/graphql-introspection-health.test.ts b/e2e/scenarios/graphql-introspection-health.test.ts new file mode 100644 index 000000000..185ec3665 --- /dev/null +++ b/e2e/scenarios/graphql-introspection-health.test.ts @@ -0,0 +1,134 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { connectEmulator } from "@executor-js/emulate"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; +import { variable } from "@executor-js/sdk/http-auth"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const api = composePluginApi([graphqlHttpPlugin()] as const); +const unique = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`; + +scenario( + "GraphQL · failed introspection blocks connection creation with an actionable error", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + const slug = unique("graphql_health"); + const emulatorBaseUrl = yield* createEmulatorInstance("github", "graphql-health"); + const emulator = yield* Effect.promise(() => + connectEmulator({ baseUrl: emulatorBaseUrl, service: "github" }), + ); + + yield* Effect.promise(() => + emulator.faults.arm({ + match: { method: "POST", pathPattern: "/graphql" }, + response: { status: 401, body: { message: "Bad credentials" } }, + times: 10, + }), + ); + + yield* client.graphql.addIntegration({ + payload: { + endpoint: `${emulatorBaseUrl}/graphql`, + slug, + name: "GraphQL health", + authenticationTemplate: [ + { + slug: "header", + type: "apiKey", + headers: { Authorization: [variable("token")] }, + }, + ], + }, + }); + + yield* Effect.gen(function* () { + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the connection flow", async () => { + await page.goto(`/integrations/${slug}?addAccount=1&owner=org&template=header`, { + waitUntil: "networkidle", + }); + await page.getByRole("heading", { name: /Add connection · GraphQL health/ }).waitFor(); + }); + + await step("Submit a credential rejected during schema introspection", async () => { + const dialog = page.getByRole("dialog", { + name: /Add connection · GraphQL health/, + }); + await dialog.getByRole("textbox", { name: "Authorization" }).fill("invalid-token"); + await dialog.getByRole("button", { name: "Continue" }).click(); + + const alert = dialog.getByRole("alert"); + await alert.waitFor(); + const message = await alert.textContent(); + expect(message).toContain("The endpoint rejected the credential with HTTP 401."); + expect(message).toContain("Check the credential and selected authentication method."); + await dialog.getByText("Step 1 of 2").waitFor(); + expect( + await page.getByText("No connections yet").count(), + "the rejected credential is not saved", + ).toBe(1); + }); + }); + + // The low-level API can still import an existing credential reference + // without the browser's preflight. This models connections created before + // the fix and proves their failed tool sync is no longer a silent zero. + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("legacy"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("header"), + value: "invalid-token", + }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + await step("A failed existing connection explains the empty tool catalogue", async () => { + await page.goto(`/integrations/${slug}?tab=tools`, { waitUntil: "networkidle" }); + await page.getByText("Connection rejected", { exact: true }).first().waitFor(); + await page + .getByText("The endpoint rejected the credential with HTTP 401.", { + exact: false, + }) + .waitFor(); + await page.getByRole("button", { name: "Check and sync tools" }).waitFor(); + }); + + await step("The account row carries the same actionable health verdict", async () => { + await page.getByRole("tab", { name: "Accounts" }).click(); + await page.getByText("Expired", { exact: true }).waitFor(); + await page + .getByText("Check the credential and selected authentication method.", { + exact: false, + }) + .waitFor(); + }); + }); + }).pipe( + Effect.ensuring( + Effect.all( + [ + client.integrations + .remove({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + Effect.promise(() => emulator.faults.clear()).pipe(Effect.ignore), + ], + { concurrency: "unbounded" }, + ), + ), + ); + }), +); diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 085164e8b..2b0ef306e 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -22,6 +22,7 @@ import { type Owner, } from "./ids"; import { definePlugin, tool, type StaticToolSchema } from "./plugin"; +import { HealthCheckResult } from "./health-check"; import { ToolPolicyActionSchema } from "./policies"; import type { Tool } from "./tool"; @@ -76,6 +77,7 @@ const ConnectionOutput = Schema.Struct({ oauthClient: Schema.NullOr(Schema.String), oauthClientOwner: Schema.NullOr(OwnerSchema), oauthScope: Schema.NullOr(Schema.String), + lastHealth: Schema.NullOr(HealthCheckResult), }); const ConnectionsListInput = Schema.Struct({ @@ -102,6 +104,7 @@ const ConnectionListItem = Schema.Struct({ oauthClientOwner: Schema.NullOr(OwnerSchema), oauthScopeCount: Schema.NullOr(Schema.Number), oauthScope: Schema.optional(Schema.NullOr(Schema.String)), + lastHealth: Schema.NullOr(HealthCheckResult), }); const ConnectionsListOutput = Schema.Struct({ connections: Schema.Array(ConnectionListItem), @@ -171,6 +174,7 @@ const ToolOutput = Schema.Struct({ }); const ConnectionsRefreshOutput = Schema.Struct({ tools: Schema.Array(ToolOutput), + lastHealth: Schema.NullOr(HealthCheckResult), }); const RemovedOutput = Schema.Struct({ removed: Schema.Boolean }); @@ -373,6 +377,7 @@ const connectionToOutput = (connection: Connection) => ({ oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient), oauthClientOwner: connection.oauthClientOwner ?? null, oauthScope: connection.oauthScope ?? null, + lastHealth: connection.lastHealth ?? null, }); /** Number of space-separated grants in an `oauthScope` string, or null when @@ -396,6 +401,7 @@ const connectionToListItem = (connection: Connection, verbose: boolean) => ({ oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient), oauthClientOwner: connection.oauthClientOwner ?? null, oauthScopeCount: oauthScopeCount(connection.oauthScope), + lastHealth: connection.lastHealth ?? null, ...(verbose ? { oauthScope: connection.oauthScope ?? null } : {}), }); @@ -556,7 +562,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "connections.list", description: - "List saved connections (the credential for one integration). Never returns the credential value. Optionally filter by integration or owner. OAuth scopes are summarized as `oauthScopeCount` by default; pass `verbose: true` to include the full `oauthScope` grant string per connection.", + "List saved connections and their last health verdict. Never returns credential values. Optionally filter by integration or owner. OAuth scopes are summarized as `oauthScopeCount` by default; pass `verbose: true` to include the full `oauthScope` grant string per connection.", inputSchema: ConnectionsListInputStd, outputSchema: ConnectionsListOutputStd, execute: (input: typeof ConnectionsListInput.Type, { ctx }) => @@ -627,7 +633,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "connections.refresh", description: - "Re-run an integration's tool production for a saved connection, replacing that connection's persisted tools.", + "Re-run an integration's tool production for a saved connection. Returns the tools and the resulting health verdict so an empty catalog is distinguishable from a failed sync.", inputSchema: ConnectionRefInputStd, outputSchema: ConnectionsRefreshOutputStd, // Refresh replaces a connection's persisted tool set; for a mutable @@ -636,9 +642,15 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { // `sources.refresh`. annotations: { requiresApproval: true }, execute: (input: typeof ConnectionRefInput.Type, { ctx }) => - Effect.map(ctx.connections.refresh(connectionRefFromInput(input)), (tools) => ({ - tools: tools.map(toolToOutput), - })), + Effect.gen(function* () { + const ref = connectionRefFromInput(input); + const tools = yield* ctx.connections.refresh(ref); + const connection = yield* ctx.connections.get(ref); + return { + tools: tools.map(toolToOutput), + lastHealth: connection?.lastHealth ?? null, + }; + }), }), // removed: tools.list — the cross-connection tool catalog is an // executor-surface read, not exposed on PluginCtx. diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index a7ec1272d..664b4d8b9 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -15,7 +15,7 @@ import { import { definePlugin } from "./plugin"; import type { CredentialProvider } from "./provider"; import { IntegrationDetectionResult } from "./types"; -import { makeTestExecutor } from "./testing"; +import { makeTestExecutor, memoryCredentialsPlugin } from "./testing"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; // removed: v1 secret browser-handoff, source.configure, case-insensitive tool-id @@ -112,6 +112,25 @@ const demoPlugin = definePlugin(() => ({ }), }))(); +const diagnosticsPlugin = definePlugin(() => ({ + id: "diagnostics" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [], + incomplete: true, + incompleteReason: "Schema introspection was rejected", + }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: IntegrationSlug.make("diagnostics"), + description: "Diagnostics", + config: {}, + }), + }), +}))(); + const detector = (id: string, confidence: IntegrationDetectionResult["confidence"]) => definePlugin(() => ({ id, @@ -269,6 +288,59 @@ describe("createExecutor", () => { }), ); + it.effect("surfaces failed tool sync diagnostics through connection tools", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const, + coreTools: {}, + }); + yield* executor.diagnostics.seed(); + + yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.create"), + { + owner: "org", + name: "main", + integration: "diagnostics", + template: "none", + }, + { onElicitation: "accept-all" }, + ); + + const listed = yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.list"), + { integration: "diagnostics" }, + ); + expect(listed).toMatchObject({ + connections: [ + { + lastHealth: { + status: "degraded", + detail: "Tool sync failing: Schema introspection was rejected", + }, + }, + ], + }); + + const refreshed = yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.refresh"), + { + owner: "org", + name: "main", + integration: "diagnostics", + }, + { onElicitation: "accept-all" }, + ); + expect(refreshed).toMatchObject({ + tools: [], + lastHealth: { + status: "degraded", + detail: "Tool sync failing: Schema introspection was rejected", + }, + }); + }), + ); + it.effect("hands pasted credential entry to the web UI", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ diff --git a/packages/plugins/graphql/src/sdk/errors.ts b/packages/plugins/graphql/src/sdk/errors.ts index 2f0b61bba..427da73b9 100644 --- a/packages/plugins/graphql/src/sdk/errors.ts +++ b/packages/plugins/graphql/src/sdk/errors.ts @@ -6,6 +6,18 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass new GraphqlIntrospectionError({ message: "Failed to reach GraphQL endpoint", + reason: "network", }), ), ); @@ -285,6 +286,9 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( message: upstreamMessage ? `Introspection failed with status ${response.status}: ${upstreamMessage}` : `Introspection failed with status ${response.status}`, + status: response.status, + reason: "http", + ...(upstreamMessage ? { upstreamMessage } : {}), }); } @@ -294,6 +298,8 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( () => new GraphqlIntrospectionError({ message: `Failed to parse introspection response as JSON`, + status: response.status, + reason: "invalid-json", }), ), ); @@ -303,22 +309,29 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( () => new GraphqlIntrospectionError({ message: "Introspection response has an invalid shape", + status: response.status, + reason: "invalid-shape", }), ), ); if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) { - const upstreamMessage = firstUpstreamErrorMessage(json); + const upstreamMessage = upstreamTextMessage(firstUpstreamErrorMessage(json) ?? ""); return yield* new GraphqlIntrospectionError({ message: upstreamMessage ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}` : `Introspection returned ${json.errors.length} error(s)`, + status: response.status, + reason: "graphql-errors", + ...(upstreamMessage ? { upstreamMessage } : {}), }); } if (!json.data?.__schema) { return yield* new GraphqlIntrospectionError({ message: "Introspection response missing __schema", + status: response.status, + reason: "missing-schema", }); } diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 505f27879..9be14ac3b 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -701,6 +701,165 @@ describe("graphqlPlugin real protocol server", () => { expect(names).toContain("mutation.setGreeting"); }), ); + + it.effect("validates an introspectable endpoint as healthy", () => + Effect.gen(function* () { + const server = yield* serveGreetingServer; + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.endpoint, + slug: "health_ok", + }); + expect( + yield* executor.integrations.healthCheck.candidates(IntegrationSlug.make("health_ok")), + ).toEqual([ + expect.objectContaining({ + operation: "__schema", + destructive: false, + }), + ]); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_ok"), + template: AuthTemplateSlug.make("none"), + value: "unused", + }); + + expect(result).toMatchObject({ + status: "healthy", + httpStatus: 200, + identity: "GraphQL schema: Query", + }); + }), + ); + + it.effect("rejects a credential blocked by an HTTP auth wall", () => + Effect.gen(function* () { + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema(), + auth: { + validateAuthorization: (authorization) => Effect.succeed(authorization === "valid-token"), + }, + }); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.endpoint, + slug: "health_http_auth", + authenticationTemplate: [ + { + slug: "header", + kind: "apikey", + placements: [{ carrier: "header", name: "Authorization", prefix: "" }], + }, + ], + }); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_http_auth"), + template: AuthTemplateSlug.make("header"), + value: "wrong-token", + }); + + expect(result.status).toBe("expired"); + expect(result.httpStatus).toBe(401); + expect(result.detail).toContain("The endpoint rejected the credential with HTTP 401."); + expect(result.detail).toContain("Check the credential and selected authentication method."); + }), + ); + + it.effect("classifies an authorization GraphQL error as an expired credential", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe({ + errors: [{ message: "Unauthorized: invalid API key" }], + }), + ), + ); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.url("/graphql"), + slug: "health_graphql_auth", + }); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_graphql_auth"), + template: AuthTemplateSlug.make("none"), + value: "unused", + }); + + expect(result.status).toBe("expired"); + expect(result.httpStatus).toBe(200); + expect(result.detail).toContain("The endpoint rejected the credential."); + expect(result.detail).toContain("Upstream said: Unauthorized: invalid API key"); + }), + ); + + it.effect("reports an invalid introspection response as degraded", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed(HttpServerResponse.jsonUnsafe({ data: {} })), + ); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.url("/graphql"), + slug: "health_invalid_shape", + }); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_invalid_shape"), + template: AuthTemplateSlug.make("none"), + value: "unused", + }); + + expect(result).toMatchObject({ + status: "degraded", + httpStatus: 200, + detail: + "The endpoint responded without a GraphQL introspection schema. Check that the URL points to a GraphQL endpoint and that introspection is enabled.", + }); + }), + ); + + it.effect("persists the introspection failure when tool sync is incomplete", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe({ message: "Bad credentials" }, { status: 401 }), + ), + ); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.url("/graphql"), + slug: "incomplete_sync", + }); + yield* createOrgConnection(executor, { + integration: "incomplete_sync", + name: "main", + template: "none", + value: "unused", + }); + + const connection = yield* executor.connections.get({ + owner: "org", + integration: IntegrationSlug.make("incomplete_sync"), + name: ConnectionName.make("main"), + }); + expect(connection?.lastHealth).toMatchObject({ + status: "degraded", + detail: "Tool sync failing: Introspection failed with status 401: Bad credentials", + }); + expect( + (yield* executor.tools.list()).filter( + (tool) => String(tool.integration) === "incomplete_sync", + ), + ).toEqual([]); + }), + ); }); describe("graphqlPlugin", () => { @@ -715,6 +874,9 @@ describe("graphqlPlugin", () => { }); expect(result.toolCount).toBe(2); expect(result.slug).toBe("test_api"); + expect( + yield* executor.integrations.healthCheck.candidates(IntegrationSlug.make("test_api")), + ).toEqual([]); yield* createOrgConnection(executor, { integration: "test_api", diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 1363b3a07..8c7da8f24 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -15,6 +15,8 @@ import { ToolName, ToolResult, type AuthMethodDescriptor, + type HealthCheckCandidate, + type HealthCheckResult, type IntegrationConfig, type IntegrationRecord, type PluginCtx, @@ -83,6 +85,116 @@ const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefi .find((message) => message !== undefined && message.length > 0); const GRAPHQL_PLUGIN_ID = "graphql"; +const GRAPHQL_HEALTH_OPERATION = "__schema"; +const GRAPHQL_HEALTH_CHECK = { operation: GRAPHQL_HEALTH_OPERATION }; +const GRAPHQL_HEALTH_CANDIDATE: HealthCheckCandidate = { + operation: GRAPHQL_HEALTH_OPERATION, + method: "post", + requiredArgCount: 0, + destructive: false, + summary: "Introspect the GraphQL schema", +}; +const GRAPHQL_INVALID_SCHEMA_DETAIL = + "The endpoint responded without a GraphQL introspection schema. Check that the URL points to a GraphQL endpoint and that introspection is enabled."; +const GRAPHQL_AUTH_DETAIL = "Check the credential and selected authentication method."; +const GRAPHQL_NETWORK_DETAIL = + "The GraphQL endpoint could not be reached. Check the URL and upstream availability, then try again."; + +const truncateHealthDetail = (text: string, max = 240): string => { + const normalized = text.replaceAll(/\s+/g, " ").trim(); + if (normalized.length <= max) return normalized; + return `${normalized.slice(0, max - 3)}...`; +}; + +const appendUpstreamMessage = (detail: string, message?: string): string => + message && message.trim().length > 0 + ? `${detail} Upstream said: ${truncateHealthDetail(message)}` + : detail; + +const isAuthMessage = (message: string | undefined): boolean => + message !== undefined && + /\b(auth|authoriz|unauthoriz|forbidden|permission|credential|token|api key|access denied)\b/i.test( + message, + ); + +const missingCredentialVariables = ( + method: GraphqlAuthMethod | undefined, + values: Record, +): readonly string[] => { + if (!method || method.kind === "none") return []; + const required = + method.kind === "oauth2" ? [TOKEN_VARIABLE] : requiredPlacementVariables(method.placements); + return required.filter((variable) => { + const value = values[variable]; + return value == null || value.length === 0; + }); +}; + +const healthFromIntrospectionError = ( + error: GraphqlIntrospectionError, + checkedAt: number, +): HealthCheckResult => { + const upstream = error.upstreamMessage; + const httpStatus = error.status; + + if (httpStatus === 401 || httpStatus === 403 || isAuthMessage(upstream)) { + const statusDetail = + httpStatus === 401 || httpStatus === 403 + ? `The endpoint rejected the credential with HTTP ${httpStatus}.` + : "The endpoint rejected the credential."; + return { + status: "expired", + ...(httpStatus !== undefined ? { httpStatus } : {}), + checkedAt, + detail: appendUpstreamMessage(`${statusDetail} ${GRAPHQL_AUTH_DETAIL}`, upstream), + }; + } + + if ( + error.reason === "invalid-json" || + error.reason === "invalid-shape" || + error.reason === "missing-schema" + ) { + return { + status: "degraded", + ...(httpStatus !== undefined ? { httpStatus } : {}), + checkedAt, + detail: GRAPHQL_INVALID_SCHEMA_DETAIL, + }; + } + + if (error.reason === "network") { + return { + status: "degraded", + checkedAt, + detail: GRAPHQL_NETWORK_DETAIL, + }; + } + + if (error.reason === "graphql-errors") { + return { + status: "degraded", + ...(httpStatus !== undefined ? { httpStatus } : {}), + checkedAt, + detail: appendUpstreamMessage( + "Schema introspection returned GraphQL errors. Check that introspection is enabled and the credential can read the schema.", + upstream, + ), + }; + } + + return { + status: "degraded", + ...(httpStatus !== undefined ? { httpStatus } : {}), + checkedAt, + detail: appendUpstreamMessage( + httpStatus !== undefined + ? `Schema introspection failed with HTTP ${httpStatus}. Check the endpoint and upstream status, then try again.` + : "Schema introspection failed. Check the endpoint and credential, then try again.", + upstream, + ), + }; +}; // --------------------------------------------------------------------------- // Extension input shapes @@ -526,6 +638,88 @@ const introspectForConnection = ( ).pipe(Effect.provide(httpClientLayer)); }; +const checkGraphqlHealth = (input: { + readonly integration: IntegrationRecord; + readonly credential: { + readonly template: AuthTemplateSlug; + readonly values: Record; + }; + readonly spec?: { readonly operation: string }; + readonly httpClientLayer: Layer.Layer; +}): Effect.Effect => + Effect.gen(function* () { + const checkedAt = Date.now(); + const decoded = yield* decodeGraphqlIntegrationConfig(input.integration.config).pipe( + Effect.option, + ); + if (Option.isNone(decoded)) { + return { + status: "unknown", + checkedAt, + detail: "The GraphQL integration configuration is invalid. Edit it, then try again.", + } satisfies HealthCheckResult; + } + + const config = decoded.value; + const operation = + input.spec?.operation ?? + (config.introspectionHash === undefined ? GRAPHQL_HEALTH_OPERATION : undefined); + if (operation === undefined) { + return { + status: "unknown", + checkedAt, + detail: "No live schema health check is configured.", + } satisfies HealthCheckResult; + } + if (operation !== GRAPHQL_HEALTH_OPERATION) { + return { + status: "unknown", + checkedAt, + detail: `GraphQL health check operation "${operation}" is not supported. Use ${GRAPHQL_HEALTH_OPERATION}.`, + } satisfies HealthCheckResult; + } + + const method = config.authenticationTemplate.find( + (candidate: GraphqlAuthMethod) => candidate.slug === String(input.credential.template), + ); + const missing = missingCredentialVariables(method, input.credential.values); + if (missing.length > 0) { + return { + status: "expired", + checkedAt, + detail: `Enter a credential value for ${missing.join(", ")}, then try again.`, + } satisfies HealthCheckResult; + } + + const auth = introspectHeadersForConnection( + config, + input.credential.values, + input.credential.template, + ); + const result = yield* introspect( + config.endpoint, + Object.keys(auth.headers).length > 0 ? auth.headers : undefined, + Object.keys(auth.queryParams).length > 0 ? auth.queryParams : undefined, + ).pipe( + Effect.provide(input.httpClientLayer), + Effect.map((introspection) => ({ ok: true as const, introspection })), + Effect.catchTag("GraphqlIntrospectionError", (error) => + Effect.succeed({ ok: false as const, error }), + ), + ); + + if (!result.ok) return healthFromIntrospectionError(result.error, checkedAt); + + const queryType = result.introspection.__schema.queryType?.name ?? "Query"; + return { + status: "healthy", + httpStatus: 200, + identity: `GraphQL schema: ${queryType}`, + checkedAt, + responseSample: [{ path: "__schema.queryType.name", value: queryType }], + } satisfies HealthCheckResult; + }); + /** Introspect an integration's endpoint (with this connection's credential), * prepare its operations, persist the bindings, and return them. Invoked from * `invokeTool` on a cache miss — i.e. when an integration was registered @@ -678,13 +872,16 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { // using that connection's credential. if (input.introspectionJson === undefined) { yield* ctx.transaction( - ctx.core.integrations.register({ - slug, - name: baseConfig.name, - description: input.description?.trim() || baseConfig.name, - config: baseConfig, - canRemove: true, - canRefresh: true, + Effect.gen(function* () { + yield* ctx.core.integrations.register({ + slug, + name: baseConfig.name, + description: input.description?.trim() || baseConfig.name, + config: baseConfig, + canRemove: true, + canRefresh: true, + }); + yield* ctx.core.integrations.setHealthCheck(slug, GRAPHQL_HEALTH_CHECK); }), ); return { slug: String(slug), name: baseConfig.name, toolCount: 0 }; @@ -773,10 +970,18 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { : current.authenticationTemplate, }); - yield* ctx.core.integrations.update(IntegrationSlug.make(slug), { - description: next.name, - config: next, - }); + const integration = IntegrationSlug.make(slug); + yield* ctx.transaction( + Effect.gen(function* () { + yield* ctx.core.integrations.update(integration, { + description: next.name, + config: next, + }); + if (next.introspectionHash === undefined) { + yield* ctx.core.integrations.setHealthCheck(integration, GRAPHQL_HEALTH_CHECK); + } + }), + ); }); /** Read the integration's decoded config (or `null` when absent / malformed). @@ -899,6 +1104,21 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { describeAuthMethods: describeGraphqlAuthMethods, describeIntegrationDisplay: describeGraphqlIntegrationDisplay, + listHealthCheckCandidates: ({ integration }) => { + const config = Option.getOrUndefined( + decodeGraphqlIntegrationConfigOption(integration.config), + ); + return Effect.succeed( + config && config.introspectionHash === undefined ? [GRAPHQL_HEALTH_CANDIDATE] : [], + ); + }, + checkHealth: (input) => + checkGraphqlHealth({ + integration: input.integration, + credential: input.credential, + spec: input.spec, + httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer, + }), staticIntegrations: (self: GraphqlPluginExtension) => [ { @@ -995,21 +1215,27 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { Effect.catch(() => Effect.succeed({} as Record)), ) : ({} as Record); - const introspection = yield* introspectForConnection( + const introspectionResult = yield* introspectForConnection( graphqlConfig, introspectionJson, values, template, options?.httpClientLayer ?? httpClientLayer, - ).pipe(Effect.option); - if (Option.isNone(introspection)) { - return incomplete("GraphQL introspection could not be loaded."); + ).pipe( + Effect.map((introspection) => ({ ok: true as const, introspection })), + Effect.catchTag("GraphqlIntrospectionError", (error) => + Effect.succeed({ ok: false as const, error }), + ), + ); + if (!introspectionResult.ok) { + return incomplete(introspectionResult.error.message); } - const extracted = yield* extract(introspection.value).pipe(Effect.option); + const introspection = introspectionResult.introspection; + const extracted = yield* extract(introspection).pipe(Effect.option); if (Option.isNone(extracted)) { return incomplete("GraphQL introspection result could not be converted to tools."); } - const prepared = prepareOperations(extracted.value.result.fields, introspection.value); + const prepared = prepareOperations(extracted.value.result.fields, introspection); return { tools: buildToolDefs(prepared), definitions: extracted.value.definitions, diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index 7cbdf6d1e..5de56c150 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -95,6 +95,8 @@ function AccountRow(props: { const displayLabel = identity ?? String(connection.name); const expired = status === "expired"; + const needsHealthAttention = status === "expired" || status === "degraded"; + const healthDetail = needsHealthAttention ? probe?.detail : undefined; const missingOAuthScopes = connection.missingOAuthScopes ?? []; const handleCheck = async () => { @@ -130,9 +132,9 @@ function AccountRow(props: { className={`size-2 shrink-0 rounded-full ${indicator.dot}`} /> {displayLabel} - {expired ? ( - - Expired + {needsHealthAttention ? ( + + {HEALTH_STATUS_LABEL[status]} ) : null} {needsReconsent ? ( @@ -146,6 +148,11 @@ function AccountRow(props: { {connection.description} ) : null} + {healthDetail ? ( + + {healthDetail} + + ) : null} {needsReconsent ? ( This connection wasn't granted all the access this integration now needs. diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index e49378b4d..64d78f231 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1767,13 +1767,9 @@ function AddAccountModalView(props: AddAccountModalProps) { if (continueError !== null) setContinueError(null); }; - // Check the key works: probe the pasted credential WITHOUT saving the - // connection. When the integration has a configured health check we run it; - // otherwise we run the inline-picked candidate and, if it comes back healthy, - // save it as the integration's health check (so it's configured "then"). - // Probe the pasted credential WITHOUT saving the connection. With a - // configured check the panel's Check runs this directly; with none it runs - // via handleCandidateProbe, which drafts a spec from the picked candidate. + // Probe the pasted credential without saving the connection. A configured + // health check runs directly; otherwise the picked candidate supplies a + // draft spec and becomes the integration's health check when it succeeds. const handleValidate = async (draftSpec?: HealthCheckSpec) => { const payloadOrigin = createCredentialPayloadOrigin({ origin: credentialOrigin, @@ -1782,7 +1778,7 @@ function AddAccountModalView(props: AddAccountModalProps) { onePasswordItemId, singleInput, }); - if (!method || payloadOrigin === null || validating) return; + if (!method || payloadOrigin === null || validating) return null; setValidating(true); const exit = await doValidate({ payload: { @@ -1799,7 +1795,7 @@ function AddAccountModalView(props: AddAccountModalProps) { if (Exit.isFailure(exit)) { setValidationResult(null); toast.error(messageFromExit(exit, "Couldn't check the key")); - return; + return null; } const result = exit.value; setValidationResult(result); @@ -1833,19 +1829,50 @@ function AddAccountModalView(props: AddAccountModalProps) { return probedIdentity; }); } + return result; }; - // No configured check: build a draft spec from the picked candidate and - // probe with it. - const handleCandidateProbe = async () => { - if (!hcCandidateReady) return; + const candidateHealthSpec = (): HealthCheckSpec | null => { + if (!hcCandidateReady) return null; const argEntries = Object.entries(hcArgs) .map(([key, value]) => [key, value.trim()] as const) .filter(([, value]) => value.length > 0); - await handleValidate({ + return { operation: hcOperation, ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), - }); + }; + }; + + // No configured check: build a draft spec from the picked candidate and + // probe with it. + const handleCandidateProbe = async () => { + const spec = candidateHealthSpec(); + if (spec) await handleValidate(spec); + }; + + const handleContinue = async () => { + if (credentialPayloadOrigin === null) { + setContinueError(singleInput ? "Enter the key first" : "Fill in every credential field"); + return; + } + + if (canCheckKey) { + const result = + validationResult?.status === "healthy" + ? validationResult + : hasHealthCheck + ? await handleValidate() + : await handleValidate(candidateHealthSpec() ?? undefined); + if (result?.status !== "healthy") { + setContinueError( + result?.detail ?? "Check the credential and resolve the connection error to continue.", + ); + return; + } + } + + setContinueError(null); + setWizardStep("place"); }; // The user clicked a field in the probe's response: that field IS the @@ -2821,20 +2848,7 @@ function AddAccountModalView(props: AddAccountModalProps) { : "Connect with OAuth"} ) : wizardActive && wizardStep === "validate" ? ( - ) : ( diff --git a/packages/react/src/components/tool-detail.tsx b/packages/react/src/components/tool-detail.tsx index cbd5fd656..822856f8e 100644 --- a/packages/react/src/components/tool-detail.tsx +++ b/packages/react/src/components/tool-detail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { toolSchemaAtom } from "../api/atoms"; @@ -506,18 +506,23 @@ function PolicyBadgeMenu(props: { // Empty state // --------------------------------------------------------------------------- -export function ToolDetailEmpty(props: { hasTools: boolean }) { +export function ToolDetailEmpty(props: { + hasTools: boolean; + title?: string; + description?: string; + action?: ReactNode; +}) { + const title = props.title ?? (props.hasTools ? "Select a tool" : "No tools available"); return (
-
-

- {props.hasTools ? "Select a tool" : "No tools available"} -

- {props.hasTools && ( +
+

{title}

+ {props.description || props.hasTools ? (

- Choose from the list to see what it does. + {props.description ?? "Choose from the list to see what it does."}

- )} + ) : null} + {props.action ?
{props.action}
: null}
); diff --git a/packages/react/src/components/tool-tree.tsx b/packages/react/src/components/tool-tree.tsx index 2005687ab..9467ddd8e 100644 --- a/packages/react/src/components/tool-tree.tsx +++ b/packages/react/src/components/tool-tree.tsx @@ -309,6 +309,8 @@ export function ToolTree(props: { * beneath each section. The same tool name can appear under two accounts (NOT * deduped). When false/unset, render one flat tree (unchanged). */ groupByConnection?: boolean; + /** Label shown when this tree has no tools and no active filter. */ + emptyLabel?: string; }) { const { tools, @@ -410,7 +412,9 @@ export function ToolTree(props: {
{filteredTools.length === 0 ? (
- {terms.length > 0 ? "No tools match your filter" : "No tools available"} + {terms.length > 0 + ? "No tools match your filter" + : (props.emptyLabel ?? "No tools available")}
) : groupByConnection ? ( accountGroups.map((group) => ( diff --git a/packages/react/src/pages/integration-detail.tsx b/packages/react/src/pages/integration-detail.tsx index cd61270a0..53c035ffe 100644 --- a/packages/react/src/pages/integration-detail.tsx +++ b/packages/react/src/pages/integration-detail.tsx @@ -13,6 +13,7 @@ import { type Owner, } from "@executor-js/sdk/shared"; import { + checkConnectionHealth, connectionsAllAtom, integrationToolsAllAtom, integrationsOptimisticAtom, @@ -21,7 +22,11 @@ import { refreshConnection, removeIntegrationOptimistic, } from "../api/atoms"; -import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys"; +import { + connectionCheckKeys, + connectionWriteKeys, + integrationWriteKeys, +} from "../api/reactivity-keys"; import { ToolTree } from "../components/tool-tree"; import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; import type { ToolSummary } from "../components/tool-tree"; @@ -36,6 +41,7 @@ import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; +import { useConnectionsHealth } from "../lib/use-connection-health"; import { integrationDetailInternalTabFromSearch, type IntegrationDetailInternalTab, @@ -74,6 +80,7 @@ export function IntegrationDetailPage(props: { const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug)); const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" }); const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" }); + const doCheckHealth = useAtomSet(checkConnectionHealth, { mode: "promiseExit" }); // Policies are owner-partitioned on write; the integration policy menu writes // Workspace (org) rules, preserving the prior default behavior. const policyActions = usePolicyActions("org"); @@ -96,6 +103,7 @@ export function IntegrationDetailPage(props: { const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [retryingTools, setRetryingTools] = useState(false); const [editSheetOpen, setEditSheetOpen] = useState(false); const [activeTab, setActiveTab] = useState(() => integrationDetailInternalTabFromSearch(props.tab), @@ -236,6 +244,25 @@ export function IntegrationDetailPage(props: { ); }, [connectionsResult, slug]); + const healthProbeFor = useConnectionsHealth(integrationConnections); + const toolsHealthIssue = useMemo(() => { + const issues = integrationConnections + .map((connection) => ({ connection, probe: healthProbeFor(connection) })) + .filter( + ( + entry, + ): entry is { + connection: Connection; + probe: NonNullable>; + } => entry.probe?.status === "expired" || entry.probe?.status === "degraded", + ) + .sort((a, b) => { + const rank = (status: string): number => (status === "expired" ? 0 : 1); + return rank(a.probe.status) - rank(b.probe.status); + }); + return issues[0] ?? null; + }, [healthProbeFor, integrationConnections]); + // Account-grouped tool rows for the Tools tab. NOT deduped across // connections: one row per (owner, connection, tool). The leaf's `name` is the // policy id `.` so leaf policy patterns stay correct, while @@ -275,6 +302,17 @@ export function IntegrationDetailPage(props: { const selection = selectedToolId ? (selectionById.get(selectedToolId) ?? null) : null; const selectedAddress = selection?.address ?? null; const selectedBareName = selection?.bareName ?? null; + const connectedEmptyTools = + !isBuiltInIntegration && integrationConnections.length > 0 && integrationTools.length === 0; + const hasToolSyncIssue = connectedEmptyTools && toolsHealthIssue !== null; + const emptyToolsTitle = toolsHealthIssue + ? toolsHealthIssue.probe.status === "expired" + ? "Connection rejected" + : "Tool sync failed" + : "Checking connection"; + const emptyToolsDescription = + toolsHealthIssue?.probe.detail ?? + "Checking whether this connection can load the GraphQL schema."; // Declared auth methods — derived server-side from the owning plugin's config // and carried on the integration catalog response. This is authoritative even @@ -356,6 +394,32 @@ export function IntegrationDetailPage(props: { setRefreshing(false); }; + const handleRetryTools = async () => { + if (retryingTools) return; + setRetryingTools(true); + let refreshedAny = false; + for (const connection of integrationConnections) { + const ref = { + owner: connection.owner, + integration: slug, + name: connection.name, + }; + const health = await doCheckHealth({ + params: ref, + query: {}, + reactivityKeys: connectionCheckKeys, + }); + if (Exit.isFailure(health) || health.value.status !== "healthy") continue; + const refreshed = await doRefresh({ + params: ref, + reactivityKeys: connectionWriteKeys, + }); + refreshedAny = refreshedAny || Exit.isSuccess(refreshed); + } + if (refreshedAny) refreshTools(); + setRetryingTools(false); + }; + const handleOpenAddConnection = () => { setActiveTab("accounts"); setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` }); @@ -504,6 +568,7 @@ export function IntegrationDetailPage(props: { onClearPolicy={(pattern) => void policyActions.clear(pattern)} policies={sortedPolicies} groupByConnection={!isBuiltInIntegration} + emptyLabel={hasToolSyncIssue ? emptyToolsTitle : undefined} />
@@ -533,6 +598,23 @@ export function IntegrationDetailPage(props: { onAddConnection={handleOpenAddConnection} canAddConnection={accountsMethods.length > 0} /> + ) : hasToolSyncIssue ? ( + void handleRetryTools()} + disabled={retryingTools} + > + {retryingTools ? "Checking…" : "Check and sync tools"} + + } + /> ) : ( 0} /> )} From 31f0d5d10f7cc0e8b72f8223b7f12c8d52a416c3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:24:26 -0700 Subject: [PATCH 2/2] review fixes: scrub credential values from health details, fix auth classifier, unify introspection failure classification, surface retry failures --- .../plugins/graphql/src/sdk/plugin.test.ts | 65 ++++++++++++++++++- packages/plugins/graphql/src/sdk/plugin.ts | 35 ++++++++-- .../react/src/pages/integration-detail.tsx | 32 ++++++++- 3 files changed, 122 insertions(+), 10 deletions(-) diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 9be14ac3b..7a9ef3161 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -798,6 +798,67 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect("classifies a bare auth GraphQL error without auth keywords near a boundary", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe({ + errors: [{ message: "Not authenticated" }], + }), + ), + ); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.url("/graphql"), + slug: "health_bare_auth", + }); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_bare_auth"), + template: AuthTemplateSlug.make("none"), + value: "unused", + }); + + expect(result.status).toBe("expired"); + expect(result.detail).toContain("Upstream said: Not authenticated"); + }), + ); + + it.effect("scrubs the probed credential value out of the health detail", () => + Effect.gen(function* () { + const secret = "sk_live_scrub_me"; + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe({ message: `Invalid API key ${secret}` }, { status: 401 }), + ), + ); + const executor = yield* makeExecutor(); + yield* executor.graphql.addIntegration({ + endpoint: server.url("/graphql"), + slug: "health_scrub", + authenticationTemplate: [ + { + slug: "header", + kind: "apikey", + placements: [{ carrier: "header", name: "Authorization", prefix: "" }], + }, + ], + }); + + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make("health_scrub"), + template: AuthTemplateSlug.make("header"), + value: secret, + }); + + expect(result.status).toBe("expired"); + expect(result.detail).not.toContain(secret); + expect(result.detail).toContain("[redacted]"); + }), + ); + it.effect("reports an invalid introspection response as degraded", () => Effect.gen(function* () { const server = yield* serveTestHttpApp(() => @@ -851,7 +912,9 @@ describe("graphqlPlugin real protocol server", () => { }); expect(connection?.lastHealth).toMatchObject({ status: "degraded", - detail: "Tool sync failing: Introspection failed with status 401: Bad credentials", + detail: + "Tool sync failing: The endpoint rejected the credential with HTTP 401. " + + "Check the credential and selected authentication method. Upstream said: Bad credentials", }); expect( (yield* executor.tools.list()).filter( diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 8c7da8f24..8b986c119 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -113,10 +113,18 @@ const appendUpstreamMessage = (detail: string, message?: string): string => const isAuthMessage = (message: string | undefined): boolean => message !== undefined && - /\b(auth|authoriz|unauthoriz|forbidden|permission|credential|token|api key|access denied)\b/i.test( + /authoriz|authenticat|forbidden|permission|credential|api.?key|access denied|access token|invalid token|token expired|logged in|sign in/i.test( message, ); +/** Upstream error text can echo the request back (URLs with query params, + * auth headers), so scrub every credential value out of anything that leaves + * as `detail` so a probe can never leak the secret it authenticated with. */ +const scrubCredentialValues = (text: string, values: Record): string => + Object.values(values) + .filter((value): value is string => typeof value === "string" && value.length > 0) + .reduce((out, secret) => out.split(secret).join("[redacted]"), text); + const missingCredentialVariables = ( method: GraphqlAuthMethod | undefined, values: Record, @@ -708,15 +716,24 @@ const checkGraphqlHealth = (input: { ), ); - if (!result.ok) return healthFromIntrospectionError(result.error, checkedAt); + if (!result.ok) { + const verdict = healthFromIntrospectionError(result.error, checkedAt); + return verdict.detail === undefined + ? verdict + : { ...verdict, detail: scrubCredentialValues(verdict.detail, input.credential.values) }; + } - const queryType = result.introspection.__schema.queryType?.name ?? "Query"; + const queryType = result.introspection.__schema.queryType?.name; return { status: "healthy", httpStatus: 200, - identity: `GraphQL schema: ${queryType}`, checkedAt, - responseSample: [{ path: "__schema.queryType.name", value: queryType }], + ...(queryType != null + ? { + identity: `GraphQL schema: ${queryType}`, + responseSample: [{ path: "__schema.queryType.name", value: queryType }], + } + : {}), } satisfies HealthCheckResult; }); @@ -1228,7 +1245,13 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { ), ); if (!introspectionResult.ok) { - return incomplete(introspectionResult.error.message); + // One classifier for introspection failures: the persisted sync + // verdict carries the same actionable text as a live health probe, + // scrubbed of the credential it authenticated with. + const verdict = healthFromIntrospectionError(introspectionResult.error, Date.now()); + return incomplete( + scrubCredentialValues(verdict.detail ?? introspectionResult.error.message, values), + ); } const introspection = introspectionResult.introspection; const extracted = yield* extract(introspection).pipe(Effect.option); diff --git a/packages/react/src/pages/integration-detail.tsx b/packages/react/src/pages/integration-detail.tsx index 53c035ffe..c93b42424 100644 --- a/packages/react/src/pages/integration-detail.tsx +++ b/packages/react/src/pages/integration-detail.tsx @@ -2,7 +2,9 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react"; import * as Exit from "effect/Exit"; +import { toast } from "sonner"; import { trackEvent } from "../api/analytics"; +import { messageFromExit } from "../api/error-reporting"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AuthTemplateSlug, @@ -398,6 +400,9 @@ export function IntegrationDetailPage(props: { if (retryingTools) return; setRetryingTools(true); let refreshedAny = false; + // The first still-unhealthy verdict (or failed call) explains why nothing + // synced; without it the button spins and stops with no visible change. + let firstProblem: string | null = null; for (const connection of integrationConnections) { const ref = { owner: connection.owner, @@ -409,14 +414,35 @@ export function IntegrationDetailPage(props: { query: {}, reactivityKeys: connectionCheckKeys, }); - if (Exit.isFailure(health) || health.value.status !== "healthy") continue; + if (Exit.isFailure(health)) { + firstProblem ??= messageFromExit(health, "Health check failed"); + continue; + } + if (health.value.status !== "healthy") { + firstProblem ??= health.value.detail ?? "The connection is still unhealthy."; + continue; + } const refreshed = await doRefresh({ params: ref, reactivityKeys: connectionWriteKeys, }); - refreshedAny = refreshedAny || Exit.isSuccess(refreshed); + if (Exit.isFailure(refreshed)) { + firstProblem ??= messageFromExit(refreshed, "Tool sync failed"); + continue; + } + refreshedAny = true; + } + trackEvent("integration_refreshed", { + integration_slug: String(slug), + connection_count: integrationConnections.length, + success: refreshedAny, + }); + if (refreshedAny) { + refreshTools(); + toast.success("Tools synced"); + } else { + toast.error(firstProblem ?? "No connections to sync"); } - if (refreshedAny) refreshTools(); setRetryingTools(false); };