History

Surface GraphQL introspection health errors

Rhys Sullivan committed 4 months ago

Commit 5b77a361 parent 281f72810 files · +879 −141Browse files

Unified · Split

10 files
  1. e2e/scenarios/graphql-invalid-format-no-tools.test.ts +244 −71
  2. packages/plugins/graphql/src/sdk/errors.ts +12 −0
  3. packages/plugins/graphql/src/sdk/introspect.ts +14 −1
  4. packages/plugins/graphql/src/sdk/plugin.test.ts +194 −0
  5. packages/plugins/graphql/src/sdk/plugin.ts +270 −37
  6. packages/react/src/components/accounts-section.tsx +10 −3
  7. packages/react/src/components/add-account-modal.tsx +34 −16
  8. packages/react/src/components/tool-detail.tsx +14 −11
  9. packages/react/src/components/tool-tree.tsx +4 −1
  10. packages/react/src/pages/integration-detail.tsx +83 −1
e2e/scenarios/graphql-invalid-format-no-tools.test.ts+244−71
@@ -1,46 +1,73 @@
1−// Cross-target (browser): GraphQL source discovery can fail silently at
2−// connection-create time. The UI journey is the reported shape: add a GraphQL
3−// integration, connect an API key, see the connection saved, then land on an
4−// empty Tools tab with no visible error explaining that introspection failed.
5−//
6−// This is intentionally a passing repro of the current buggy behavior. The
7−// upstream is a local node:http server that returns HTTP 200 with a GraphQL-ish
8−// body in the wrong introspection shape: `{ "data": {} }`.
1+// Cross-target (browser): adding a GraphQL source whose endpoint responds with
2+// a non-introspection shape must fail at credential validation with actionable
3+// copy, not save a connected account with an empty tool catalog.
94 import { randomBytes } from "node:crypto";
10−import { createServer } from "node:http";
5+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
116
127 import { expect } from "@effect/vitest";
138 import { Effect } from "effect";
149 import { composePluginApi } from "@executor-js/api/server";
15−import { ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
10+import { ConnectionName, IntegrationSlug, type Owner } from "@executor-js/sdk/shared";
11+import type { Page } from "playwright";
1612
1713 import { scenario } from "../src/scenario";
1814 import { Api, Browser, Target } from "../src/services";
1915
2016 const api = composePluginApi([] as const);
17+const VALID_KEY = "lin_test_key";
18+const INVALID_SHAPE_COPY =
19+ "The endpoint responded, but not with a GraphQL schema. Check that the URL points to a GraphQL endpoint";
2120
22−const serveInvalidIntrospectionGraphqlApi = () =>
21+const healthyIntrospection = {
22+ data: {
23+ __schema: {
24+ queryType: { name: "Query" },
25+ mutationType: null,
26+ types: [
27+ {
28+ kind: "OBJECT",
29+ name: "Query",
30+ description: null,
31+ fields: [
32+ {
33+ name: "viewer",
34+ description: "Current viewer",
35+ args: [],
36+ type: { kind: "SCALAR", name: "String", ofType: null },
37+ },
38+ ],
39+ inputFields: null,
40+ enumValues: null,
41+ },
42+ {
43+ kind: "SCALAR",
44+ name: "String",
45+ description: null,
46+ fields: null,
47+ inputFields: null,
48+ enumValues: null,
49+ },
50+ ],
51+ },
52+ },
53+};
54+
55+const closeServer = (server: ReturnType<typeof createServer>) => {
56+ server.close();
57+ server.closeAllConnections();
58+};
59+
60+const serveGraphqlApi = (handler: (request: IncomingMessage, response: ServerResponse) => void) =>
2361 Effect.acquireRelease(
2462 Effect.callback<{ readonly endpoint: string; readonly close: () => void }>((resume) => {
25− const server = createServer((request, response) => {
26− if (request.method === "POST" && (request.url ?? "").startsWith("/graphql")) {
27− response.writeHead(200, { "content-type": "application/json" });
28− response.end(JSON.stringify({ data: {} }));
29− return;
30− }
31− response.writeHead(404, { "content-type": "application/json" });
32− response.end(JSON.stringify({ error: "not_found" }));
33− });
63+ const server = createServer(handler);
3464 server.listen(0, "127.0.0.1", () => {
3565 const address = server.address();
3666 const port = typeof address === "object" && address ? address.port : 0;
3767 resume(
3868 Effect.succeed({
3969 endpoint: `http://127.0.0.1:${port}/graphql`,
40− close: () => {
41− server.close();
42− server.closeAllConnections();
43− },
70+ close: () => closeServer(server),
4471 }),
4572 );
4673 });
@@ -48,8 +75,102 @@ const serveInvalidIntrospectionGraphqlApi = () => · 1 unmodified line
4875 (server) => Effect.sync(server.close),
4976 );
5077
78+const serveInvalidIntrospectionGraphqlApi = () =>
79+ serveGraphqlApi((request, response) => {
80+ if (request.method === "POST" && (request.url ?? "").startsWith("/graphql")) {
81+ response.writeHead(200, { "content-type": "application/json" });
82+ response.end(JSON.stringify({ data: {} }));
83+ return;
84+ }
85+ response.writeHead(404, { "content-type": "application/json" });
86+ response.end(JSON.stringify({ error: "not_found" }));
87+ });
88+
89+const serveHealthyGraphqlApi = () =>
90+ serveGraphqlApi((request, response) => {
91+ if (request.method !== "POST" || !(request.url ?? "").startsWith("/graphql")) {
92+ response.writeHead(404, { "content-type": "application/json" });
93+ response.end(JSON.stringify({ error: "not_found" }));
94+ return;
95+ }
96+ if (request.headers.authorization !== VALID_KEY) {
97+ response.writeHead(401, { "content-type": "application/json" });
98+ response.end(JSON.stringify({ errors: [{ message: "Unauthorized" }] }));
99+ return;
100+ }
101+ let body = "";
102+ request.setEncoding("utf8");
103+ request.on("data", (chunk: string) => {
104+ body += chunk;
105+ });
106+ request.on("end", () => {
107+ const query = body.includes("__schema") ? "__schema" : "viewer";
108+ response.writeHead(200, { "content-type": "application/json" });
109+ response.end(
110+ JSON.stringify(
111+ query === "__schema" ? healthyIntrospection : { data: { viewer: "Test User" } },
112+ ),
113+ );
114+ });
115+ });
116+
117+type CleanupClient = {
118+ readonly connections: {
119+ readonly list: (input: {
120+ readonly query: { readonly integration: IntegrationSlug };
121+ }) => Effect.Effect<
122+ readonly { readonly owner: Owner; readonly name: ConnectionName | string }[],
123+ unknown
124+ >;
125+ readonly remove: (input: {
126+ readonly params: {
127+ readonly owner: Owner;
128+ readonly integration: IntegrationSlug;
129+ readonly name: ConnectionName;
130+ };
131+ }) => Effect.Effect<unknown, unknown>;
132+ };
133+ readonly integrations: {
134+ readonly remove: (input: {
135+ readonly params: { readonly slug: IntegrationSlug };
136+ }) => Effect.Effect<unknown, unknown>;
137+ };
138+};
139+
140+const cleanupIntegration = (apiClient: CleanupClient, slug: IntegrationSlug) =>
141+ Effect.gen(function* () {
142+ const connections = yield* apiClient.connections
143+ .list({ query: { integration: slug } })
144+ .pipe(Effect.catch(() => Effect.succeed([])));
145+ for (const connection of connections) {
146+ yield* apiClient.connections
147+ .remove({
148+ params: {
149+ owner: connection.owner,
150+ integration: slug,
151+ name: ConnectionName.make(String(connection.name)),
152+ },
153+ })
154+ .pipe(Effect.ignore);
155+ }
156+ yield* apiClient.integrations.remove({ params: { slug } }).pipe(Effect.ignore);
157+ });
158+
159+const fillGraphqlSourceForm = async (
160+ page: Page,
161+ input: { readonly endpoint: string; readonly slug: IntegrationSlug; readonly name: string },
162+) => {
163+ await page.getByPlaceholder("https://api.example.com/graphql").fill(input.endpoint);
164+ await page.getByPlaceholder("e.g. Shopify API").fill(input.name);
165+ await page.locator("input").nth(2).fill(String(input.slug));
166+ await page.getByRole("button", { name: "Add method" }).click();
167+ await page.getByText("Method 1").waitFor();
168+ await page.getByLabel("Header name").fill("Authorization");
169+ await page.getByLabel("Prefix").fill("");
170+};
171+
51172 scenario(
52− "GraphQL · invalid introspection format creates a connected account with zero tools",
173+ "GraphQL · invalid introspection format shows an actionable connection error",
53174 {},
54175 Effect.scoped(
55176 Effect.gen(function* () {
@@ -71,17 +192,13 @@ scenario( · 15 unmodified lines
71192 });
72193
73194 await step(
74− "Enter the endpoint and declare Linear-style Authorization API key auth",
195+ "Enter an invalid-shape endpoint with Authorization API key auth",
75196 async () => {
76− await page
77− .getByPlaceholder("https://api.example.com/graphql")
78− .fill(upstream.endpoint);
79− await page.getByPlaceholder("e.g. Shopify API").fill("Linear GraphQL");
80− await page.locator("input").nth(2).fill(String(slug));
81− await page.getByRole("button", { name: "Add method" }).click();
82− await page.getByText("Method 1").waitFor();
83− await page.getByLabel("Header name").fill("Authorization");
84− await page.getByLabel("Prefix").fill("");
197+ await fillGraphqlSourceForm(page, {
198+ endpoint: upstream.endpoint,
199+ slug,
200+ name: "Linear GraphQL",
201+ });
85202 },
86203 );
87204
@@ -92,60 +209,116 @@ scenario( · 4 unmodified lines
92209 await page.getByText("No connections yet").waitFor();
93210 });
94211
95− await step("Connect an API key", async () => {
212+ await step("Try to connect an API key and see the schema-format error", async () => {
96213 await page.getByRole("button", { name: "Add connection" }).first().click();
97214 const dialog = page.getByRole("dialog");
98215 await dialog.getByRole("tab", { name: "API key (Authorization)" }).waitFor();
99− await dialog.getByRole("textbox", { name: "Authorization" }).fill("lin_test_key");
216+ await dialog.getByRole("textbox", { name: "Authorization" }).fill(VALID_KEY);
100217 await dialog.getByRole("button", { name: "Continue" }).click();
101− await dialog.getByLabel("Display name").fill("main");
102− await dialog.getByRole("button", { name: "Add connection" }).click();
103− await page.getByText("Connection added").waitFor({ timeout: 30_000 });
104− await page.getByText("main").waitFor();
218+ await dialog.getByRole("alert").getByText(INVALID_SHAPE_COPY).waitFor({
219+ timeout: 30_000,
220+ });
221+ await dialog.getByRole("alert").getByText("https://api.linear.app/graphql").waitFor();
222+ expect(await dialog.getByLabel("Display name").count()).toBe(0);
223+ await dialog.getByRole("button", { name: "Cancel" }).click();
105224 });
106225
107− await step("Open Tools and observe the empty catalog with no error", async () => {
226+ await step("Open Tools and confirm there is no bare empty-tools state", async () => {
108227 await page.getByRole("tab", { name: "Tools" }).click();
109− await page.getByPlaceholder("Filter 0 tools…").waitFor({ timeout: 30_000 });
110− await page.getByText("No tools available").first().waitFor();
111−
112− const visibleErrorCopy = await page
113− .getByText(
114− /Failed to add connection|Failed to load tools|Introspection failed|invalid shape/i,
115− )
116− .count();
117− expect(
118− visibleErrorCopy,
119− "no visible error explains the invalid introspection body",
120− ).toBe(0);
228+ await page.getByText("No tools yet").first().waitFor({ timeout: 30_000 });
229+ expect(await page.getByText("No tools available").count()).toBe(0);
121230 });
122231 });
123232
124233 const connections = yield* apiClient.connections.list({
125234 query: { integration: slug },
126235 });
127− expect(connections.length, "the connection was created").toBe(1);
236+ expect(
237+ connections.length,
238+ "invalid credential validation did not save a connection",
239+ ).toBe(0);
128240
129241 const tools = yield* apiClient.tools.list({ query: { integration: slug } });
130− expect(tools, "the connected GraphQL integration produced no tools").toEqual([]);
242+ expect(tools, "the invalid integration produced no tools").toEqual([]);
131243 }),
244+ cleanupIntegration(apiClient, slug),
245+ );
246+ }),
247+ ),
248+);
249+
250+scenario(
251+ "GraphQL · valid introspection connects healthy and generates tools",
252+ {},
253+ Effect.scoped(
254+ Effect.gen(function* () {
255+ const target = yield* Target;
256+ const browser = yield* Browser;
257+ const { client } = yield* Api;
258+ const upstream = yield* serveHealthyGraphqlApi();
259+ const identity = yield* target.newIdentity();
260+ const apiClient = yield* client(api, identity);
261+
262+ const slug = IntegrationSlug.make(`graphql_valid_format_${randomBytes(4).toString("hex")}`);
263+
264+ yield* Effect.ensuring(
132265 Effect.gen(function* () {
133− const connections = yield* apiClient.connections
134− .list({ query: { integration: slug } })
135− .pipe(Effect.catch(() => Effect.succeed([])));
136− for (const connection of connections) {
137− yield* apiClient.connections
138− .remove({
139− params: {
140− owner: connection.owner,
141− integration: slug,
142− name: ConnectionName.make(String(connection.name)),
143− },
144− })
145− .pipe(Effect.ignore);
146− }
147− yield* apiClient.integrations.remove({ params: { slug } }).pipe(Effect.ignore);
266+ yield* browser.session(identity, async ({ page, step }) => {
267+ await step("Open the Add GraphQL integration form", async () => {
268+ await page.goto("/integrations/add/graphql", { waitUntil: "networkidle" });
269+ await page.getByRole("heading", { name: "Add GraphQL integration" }).waitFor();
270+ });
271+
272+ await step(
273+ "Enter a valid GraphQL endpoint with Authorization API key auth",
274+ async () => {
275+ await fillGraphqlSourceForm(page, {
276+ endpoint: upstream.endpoint,
277+ slug,
278+ name: "Healthy GraphQL",
279+ });
280+ },
281+ );
282+
283+ await step("Add the valid GraphQL integration", async () => {
284+ await page.getByRole("button", { name: "Add integration" }).click();
285+ await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
286+ await page.getByText("Connections").first().waitFor();
287+ await page.getByText("No connections yet").waitFor();
288+ });
289+
290+ await step("Connect an API key and see the healthy schema check", async () => {
291+ await page.getByRole("button", { name: "Add connection" }).first().click();
292+ const dialog = page.getByRole("dialog");
293+ await dialog.getByRole("tab", { name: "API key (Authorization)" }).waitFor();
294+ await dialog.getByRole("textbox", { name: "Authorization" }).fill(VALID_KEY);
295+ await dialog.getByRole("button", { name: "Continue" }).click();
296+ await dialog.getByText("Healthy").waitFor({ timeout: 30_000 });
297+ await dialog.getByText("GraphQL schema: Query").waitFor();
298+ await dialog.getByLabel("Display name").fill("main");
299+ await dialog.locator("button").filter({ hasText: "Add connection" }).click();
300+ await page.getByText("Connection added").waitFor({ timeout: 30_000 });
301+ await page.getByText("GraphQL schema: Query").waitFor();
302+ });
303+
304+ await step("Open Tools and see generated GraphQL tools", async () => {
305+ await page.getByRole("tab", { name: "Tools" }).click();
306+ await page.getByPlaceholder("Filter 1 tools…").waitFor({ timeout: 30_000 });
307+ await page.getByLabel("Filter tools").fill("viewer");
308+ await page.getByText("viewer").first().waitFor();
309+ });
310+ });
311+
312+ const connections = yield* apiClient.connections.list({
313+ query: { integration: slug },
314+ });
315+ expect(connections.length, "the valid connection was created").toBe(1);
316+ expect(connections[0]?.lastHealth?.status).toBe("healthy");
317+
318+ const tools = yield* apiClient.tools.list({ query: { integration: slug } });
319+ expect(tools.length, "the valid GraphQL integration produced tools").toBeGreaterThan(0);
148320 }),
321+ cleanupIntegration(apiClient, slug),
149322 );
150323 }),
151324 ),
packages/plugins/graphql/src/sdk/errors.ts+12−0
@@ -6,6 +6,18 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass<GraphqlIn · 5 unmodified lines
66 "GraphqlIntrospectionError",
77 {
88 message: Schema.String,
9+ status: Schema.optional(Schema.Number),
10+ reason: Schema.optional(
11+ Schema.Literals([
12+ "network",
13+ "http",
14+ "invalid-json",
15+ "invalid-shape",
16+ "missing-schema",
17+ "graphql-errors",
18+ ]),
19+ ),
20+ upstreamMessage: Schema.optional(Schema.String),
921 },
1022 ) {}
1123
packages/plugins/graphql/src/sdk/introspect.ts+14−1
@@ -267,6 +267,7 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 266 unmodified lines
267267 () =>
268268 new GraphqlIntrospectionError({
269269 message: "Failed to reach GraphQL endpoint",
270+ reason: "network",
270271 }),
271272 ),
272273 );
@@ -285,6 +286,9 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 12 unmodified lines
285286 message: upstreamMessage
286287 ? `Introspection failed with status ${response.status}: ${upstreamMessage}`
287288 : `Introspection failed with status ${response.status}`,
289+ status: response.status,
290+ reason: "http",
291+ ...(upstreamMessage ? { upstreamMessage } : {}),
288292 });
289293 }
290294
@@ -294,6 +298,8 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 3 unmodified lines
294298 () =>
295299 new GraphqlIntrospectionError({
296300 message: `Failed to parse introspection response as JSON`,
301+ status: response.status,
302+ reason: "invalid-json",
297303 }),
298304 ),
299305 );
@@ -303,22 +309,29 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 3 unmodified lines
303309 () =>
304310 new GraphqlIntrospectionError({
305311 message: "Introspection response has an invalid shape",
312+ status: response.status,
313+ reason: "invalid-shape",
306314 }),
307315 ),
308316 );
309317
310318 if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) {
311− const upstreamMessage = firstUpstreamErrorMessage(json);
319+ const upstreamMessage = upstreamTextMessage(firstUpstreamErrorMessage(json) ?? "");
312320 return yield* new GraphqlIntrospectionError({
313321 message: upstreamMessage
314322 ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}`
315323 : `Introspection returned ${json.errors.length} error(s)`,
324+ status: response.status,
325+ reason: "graphql-errors",
326+ ...(upstreamMessage ? { upstreamMessage } : {}),
316327 });
317328 }
318329
319330 if (!json.data?.__schema) {
320331 return yield* new GraphqlIntrospectionError({
321332 message: "Introspection response missing __schema",
333+ status: response.status,
334+ reason: "missing-schema",
322335 });
323336 }
324337
packages/plugins/graphql/src/sdk/plugin.test.ts+194−0
@@ -567,6 +567,200 @@ describe("graphqlPlugin real protocol server", () => { · 566 unmodified lines
567567 expect(names).toContain("mutation.setGreeting");
568568 }),
569569 );
570+
571+ it.effect("checkHealth reports healthy for an introspectable endpoint", () =>
572+ Effect.gen(function* () {
573+ const server = yield* serveGreetingServer;
574+ const executor = yield* makeExecutor();
575+
576+ yield* executor.graphql.addIntegration({
577+ endpoint: server.endpoint,
578+ slug: "health_ok",
579+ });
580+
581+ const result = yield* executor.connections.validate({
582+ owner: "org",
583+ integration: IntegrationSlug.make("health_ok"),
584+ template: AuthTemplateSlug.make("none"),
585+ value: "unused",
586+ });
587+
588+ expect(result).toMatchObject({
589+ status: "healthy",
590+ httpStatus: 200,
591+ identity: "GraphQL schema: Query",
592+ });
593+ }),
594+ );
595+
596+ it.effect("checkHealth reports expired for an HTTP auth wall", () =>
597+ Effect.gen(function* () {
598+ const server = yield* serveGraphqlTestServer({
599+ schema: makeGreetingGraphqlSchema(),
600+ auth: {
601+ validateAuthorization: (authorization) =>
602+ Effect.succeed(authorization === "lin_test_key"),
603+ },
604+ });
605+ const executor = yield* makeExecutor();
606+
607+ yield* executor.graphql.addIntegration({
608+ endpoint: server.endpoint,
609+ slug: "health_http_auth",
610+ authenticationTemplate: [
611+ {
612+ slug: "header",
613+ kind: "apikey",
614+ placements: [{ carrier: "header", name: "Authorization", prefix: "" }],
615+ },
616+ ],
617+ });
618+
619+ const result = yield* executor.connections.validate({
620+ owner: "org",
621+ integration: IntegrationSlug.make("health_http_auth"),
622+ template: AuthTemplateSlug.make("header"),
623+ value: "wrong",
624+ });
625+
626+ expect(result.status).toBe("expired");
627+ expect(result.httpStatus).toBe(401);
628+ expect(result.detail).toContain("The endpoint rejected the credential (401).");
629+ expect(result.detail).toContain(
630+ "raw key in the Authorization header without a Bearer prefix",
631+ );
632+ expect(result.detail).toContain("Upstream said: Unauthorized");
633+ }),
634+ );
635+
636+ it.effect("checkHealth includes GraphQL errors payloads in auth verdicts", () =>
637+ Effect.gen(function* () {
638+ const server = yield* serveTestHttpApp(() =>
639+ Effect.succeed(
640+ HttpServerResponse.jsonUnsafe({
641+ errors: [{ message: "Unauthorized: invalid API key" }],
642+ }),
643+ ),
644+ );
645+ const executor = yield* makeExecutor();
646+
647+ yield* executor.graphql.addIntegration({
648+ endpoint: server.url("/graphql"),
649+ slug: "health_graphql_auth",
650+ });
651+
652+ const result = yield* executor.connections.validate({
653+ owner: "org",
654+ integration: IntegrationSlug.make("health_graphql_auth"),
655+ template: AuthTemplateSlug.make("none"),
656+ value: "unused",
657+ });
658+
659+ expect(result.status).toBe("expired");
660+ expect(result.httpStatus).toBe(200);
661+ expect(result.detail).toContain("The endpoint rejected the credential.");
662+ expect(result.detail).toContain("Upstream said: Unauthorized: invalid API key");
663+ }),
664+ );
665+
666+ it.effect("checkHealth reports degraded for an invalid introspection shape", () =>
667+ Effect.gen(function* () {
668+ const server = yield* serveTestHttpApp(() =>
669+ Effect.succeed(HttpServerResponse.jsonUnsafe({ data: {} })),
670+ );
671+ const executor = yield* makeExecutor();
672+
673+ yield* executor.graphql.addIntegration({
674+ endpoint: server.url("/graphql"),
675+ slug: "health_invalid_shape",
676+ });
677+
678+ const result = yield* executor.connections.validate({
679+ owner: "org",
680+ integration: IntegrationSlug.make("health_invalid_shape"),
681+ template: AuthTemplateSlug.make("none"),
682+ value: "unused",
683+ });
684+
685+ expect(result.status).toBe("degraded");
686+ expect(result.httpStatus).toBe(200);
687+ expect(result.detail).toBe(
688+ "The endpoint responded, but not with a GraphQL schema. Check that the URL points to a GraphQL endpoint (for Linear: https://api.linear.app/graphql).",
689+ );
690+ }),
691+ );
692+
693+ it.effect("checkHealth reports degraded for an unreachable endpoint", () =>
694+ Effect.gen(function* () {
695+ const executor = yield* makeExecutor();
696+
697+ yield* executor.graphql.addIntegration({
698+ endpoint: "http://127.0.0.1:1/graphql",
699+ slug: "health_unreachable",
700+ });
701+
702+ const result = yield* executor.connections.validate({
703+ owner: "org",
704+ integration: IntegrationSlug.make("health_unreachable"),
705+ template: AuthTemplateSlug.make("none"),
706+ value: "unused",
707+ });
708+
709+ expect(result.status).toBe("degraded");
710+ expect(result.detail).toBe(
711+ "We couldn't reach the GraphQL endpoint. Check that the URL is correct and reachable, then try again.",
712+ );
713+ }),
714+ );
715+
716+ it.effect(
717+ "resolveTools returns incomplete and keeps existing tools after introspection fails",
718+ () =>
719+ Effect.gen(function* () {
720+ const goodServer = yield* serveGreetingServer;
721+ const badServer = yield* serveTestHttpApp(() =>
722+ Effect.succeed(HttpServerResponse.jsonUnsafe({ data: {} })),
723+ );
724+ const executor = yield* makeExecutor();
725+
726+ yield* executor.graphql.addIntegration({
727+ endpoint: goodServer.endpoint,
728+ slug: "incomplete_refresh",
729+ });
730+ yield* createOrgConnection(executor, {
731+ integration: "incomplete_refresh",
732+ name: "main",
733+ template: "none",
734+ value: "unused",
735+ });
736+
737+ const initialTools = yield* executor.tools.list();
738+ expect(
739+ initialTools
740+ .filter((tool) => String(tool.integration) === "incomplete_refresh")
741+ .map((tool) => String(tool.name)),
742+ ).toEqual(expect.arrayContaining(["query.hello", "mutation.setGreeting"]));
743+
744+ yield* executor.graphql.configure("incomplete_refresh", {
745+ endpoint: badServer.url("/graphql"),
746+ });
747+ const refreshed = yield* executor.connections.refresh({
748+ owner: "org",
749+ integration: IntegrationSlug.make("incomplete_refresh"),
750+ name: ConnectionName.make("main"),
751+ });
752+
753+ expect(refreshed.map((tool) => String(tool.name))).toEqual(
754+ expect.arrayContaining(["query.hello", "mutation.setGreeting"]),
755+ );
756+ const keptTools = yield* executor.tools.list();
757+ expect(
758+ keptTools
759+ .filter((tool) => String(tool.integration) === "incomplete_refresh")
760+ .map((tool) => String(tool.name)),
761+ ).toEqual(expect.arrayContaining(["query.hello", "mutation.setGreeting"]));
762+ }),
763+ );
570764 });
571765
572766 describe("graphqlPlugin", () => {
packages/plugins/graphql/src/sdk/plugin.ts+270−37
@@ -11,9 +11,12 @@ import { · 10 unmodified lines
1111 IntegrationSlug,
1212 mergeAuthTemplates,
1313 sha256Hex,
14+ StorageError,
1415 ToolName,
1516 ToolResult,
1617 type AuthMethodDescriptor,
18+ type HealthCheckCandidate,
19+ type HealthCheckResult,
1720 type IntegrationConfig,
1821 type IntegrationRecord,
1922 type PluginCtx,
@@ -44,6 +47,7 @@ import { · 24 unmodified lines
4447 import { extract } from "./extract";
4548 import {
4649 GraphqlAuthRequiredError,
50+ GraphqlExtractionError,
4751 GraphqlIntrospectionError,
4852 GraphqlInvocationError,
4953 } from "./errors";
@@ -76,12 +80,132 @@ const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody); · 26 unmodified lines
7680 const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined =>
7781 Option.getOrUndefined(decodeGraphqlErrorsBody(errors));
7882
83+const encodeJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
84+const parseUrlOption = Option.liftThrowable((value: string) => new URL(value));
85+
7986 const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined =>
8087 errors
8188 .map((error) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message)
8289 .find((message) => message !== undefined && message.length > 0);
8390
8491 const GRAPHQL_PLUGIN_ID = "graphql";
92+const GRAPHQL_HEALTH_OPERATION = "__schema";
93+const GRAPHQL_HEALTH_CHECK = { operation: GRAPHQL_HEALTH_OPERATION };
94+const GRAPHQL_HEALTH_CANDIDATE: HealthCheckCandidate = {
95+ operation: GRAPHQL_HEALTH_OPERATION,
96+ method: "post",
97+ requiredArgCount: 0,
98+ destructive: false,
99+ summary: "Introspect the GraphQL schema",
100+};
101+const GRAPHQL_INVALID_SCHEMA_DETAIL =
102+ "The endpoint responded, but not with a GraphQL schema. Check that the URL points to a GraphQL endpoint (for Linear: https://api.linear.app/graphql).";
103+const GRAPHQL_AUTH_DETAIL =
104+ "Check the API key and auth method. Some APIs, including Linear, expect the raw key in the Authorization header without a Bearer prefix.";
105+const GRAPHQL_NETWORK_DETAIL =
106+ "We couldn't reach the GraphQL endpoint. Check that the URL is correct and reachable, then try again.";
107+const GRAPHQL_CONFIG_DETAIL =
108+ "The GraphQL integration config is invalid. Edit the endpoint and auth method, then try again.";
109+
110+const truncateHealthDetail = (text: string, max = 240): string => {
111+ const normalized = text.replaceAll(/\s+/g, " ").trim();
112+ if (normalized.length <= max) return normalized;
113+ return `${normalized.slice(0, max - 3)}...`;
114+};
115+
116+const appendUpstreamMessage = (detail: string, message?: string): string =>
117+ message && message.trim().length > 0
118+ ? `${detail} Upstream said: ${truncateHealthDetail(message)}`
119+ : detail;
120+
121+const isAuthMessage = (message: string | undefined): boolean =>
122+ message !== undefined &&
123+ /\b(auth|authoriz|unauthoriz|forbidden|permission|credential|token|api key|access denied)\b/i.test(
124+ message,
125+ );
126+
127+const missingCredentialVariables = (
128+ method: GraphqlAuthMethod | undefined,
129+ values: Record<string, string | null>,
130+): readonly string[] => {
131+ if (!method || method.kind === "none") return [];
132+ const required =
133+ method.kind === "oauth2" ? [TOKEN_VARIABLE] : requiredPlacementVariables(method.placements);
134+ return required.filter((variable) => {
135+ const value = values[variable];
136+ return value == null || value.length === 0;
137+ });
138+};
139+
140+const healthFromIntrospectionError = (
141+ error: GraphqlIntrospectionError,
142+ checkedAt: number,
143+): HealthCheckResult => {
144+ const upstream = error.upstreamMessage;
145+ const httpStatus = error.status;
146+ const withStatus =
147+ httpStatus === 401 || httpStatus === 403
148+ ? `The endpoint rejected the credential (${httpStatus}).`
149+ : "";
150+ if (httpStatus === 401 || httpStatus === 403 || isAuthMessage(upstream)) {
151+ return {
152+ status: "expired",
153+ ...(httpStatus !== undefined ? { httpStatus } : {}),
154+ checkedAt,
155+ detail: appendUpstreamMessage(
156+ withStatus.length > 0
157+ ? `${withStatus} ${GRAPHQL_AUTH_DETAIL}`
158+ : `The endpoint rejected the credential. ${GRAPHQL_AUTH_DETAIL}`,
159+ upstream,
160+ ),
161+ };
162+ }
163+
164+ if (
165+ error.reason === "invalid-json" ||
166+ error.reason === "invalid-shape" ||
167+ error.reason === "missing-schema"
168+ ) {
169+ return {
170+ status: "degraded",
171+ ...(httpStatus !== undefined ? { httpStatus } : {}),
172+ checkedAt,
173+ detail: GRAPHQL_INVALID_SCHEMA_DETAIL,
174+ };
175+ }
176+
177+ if (error.reason === "network") {
178+ return {
179+ status: "degraded",
180+ checkedAt,
181+ detail: GRAPHQL_NETWORK_DETAIL,
182+ };
183+ }
184+
185+ if (error.reason === "graphql-errors") {
186+ return {
187+ status: "degraded",
188+ ...(httpStatus !== undefined ? { httpStatus } : {}),
189+ checkedAt,
190+ detail: appendUpstreamMessage(
191+ "The endpoint returned GraphQL errors during schema introspection. Check that introspection is enabled and that the credential can read the schema.",
192+ upstream,
193+ ),
194+ };
195+ }
196+
197+ return {
198+ status: "degraded",
199+ ...(httpStatus !== undefined ? { httpStatus } : {}),
200+ checkedAt,
201+ detail: appendUpstreamMessage(
202+ httpStatus !== undefined
203+ ? `The GraphQL endpoint returned HTTP ${httpStatus} during schema introspection. Check the endpoint URL and upstream status, then try again.`
204+ : "The GraphQL schema check failed. Check the endpoint URL and credential, then try again.",
205+ upstream,
206+ ),
207+ };
208+};
85209
86210 // ---------------------------------------------------------------------------
87211 // Extension input shapes
@@ -538,7 +662,10 @@ const materializeOperations = ( · 450 unmodified lines
538662 readonly values: Record<string, string | null>;
539663 },
540664 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
541−): Effect.Effect<readonly StoredOperation[], GraphqlIntrospectionError | StorageFailure> =>
665+): Effect.Effect<
666+ readonly StoredOperation[],
667+ GraphqlIntrospectionError | GraphqlExtractionError | StorageFailure
668+> =>
542669 Effect.gen(function* () {
543670 // Render the exact method this connection references (we have its slug
544671 // here, unlike `resolveTools`) so an auth-required endpoint introspects.
@@ -565,15 +692,7 @@ const materializeOperations = ( · 20 unmodified lines
565692 Object.keys(queryParams).length > 0 ? queryParams : undefined,
566693 ).pipe(Effect.provide(httpClientLayer));
567694
568− const { result } = yield* extract(introspection).pipe(
569− Effect.catch(() =>
570− Effect.succeed({
571− result: { fields: [] as readonly ExtractedField[] },
572− } as {
573− readonly result: { readonly fields: readonly ExtractedField[] };
574− }),
575− ),
576− );
695+ const { result } = yield* extract(introspection);
577696 const prepared = prepareOperations(result.fields, introspection);
578697 const stored = toStoredOperations(IntegrationSlug.make(integration), prepared);
579698 yield* ctx.storage.replaceOperations(integration, stored);
@@ -621,6 +740,86 @@ export const describeGraphqlIntegrationDisplay = ( · 41 unmodified lines
621740 return { url: config?.endpoint };
622741 };
623742
743+const checkGraphqlHealth = (input: {
744+ readonly integration: IntegrationRecord;
745+ readonly credential: {
746+ readonly template: AuthTemplateSlug;
747+ readonly values: Record<string, string | null>;
748+ };
749+ readonly spec?: { readonly operation: string };
750+ readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
751+}): Effect.Effect<HealthCheckResult, never> =>
752+ Effect.gen(function* () {
753+ const checkedAt = Date.now();
754+ const spec = input.spec;
755+ if (!spec) {
756+ return {
757+ status: "unknown",
758+ checkedAt,
759+ detail: "No health check configured.",
760+ } satisfies HealthCheckResult;
761+ }
762+ if (spec.operation !== GRAPHQL_HEALTH_OPERATION) {
763+ return {
764+ status: "unknown",
765+ checkedAt,
766+ detail: `GraphQL health check operation "${spec.operation}" is not supported. Use ${GRAPHQL_HEALTH_OPERATION}.`,
767+ } satisfies HealthCheckResult;
768+ }
769+
770+ const decoded = yield* decodeGraphqlIntegrationConfig(input.integration.config).pipe(
771+ Effect.option,
772+ );
773+ if (Option.isNone(decoded)) {
774+ return {
775+ status: "unknown",
776+ checkedAt,
777+ detail: GRAPHQL_CONFIG_DETAIL,
778+ } satisfies HealthCheckResult;
779+ }
780+
781+ const config = decoded.value;
782+ const method = config.authenticationTemplate.find(
783+ (candidate: GraphqlAuthMethod) => candidate.slug === String(input.credential.template),
784+ );
785+ const missing = missingCredentialVariables(method, input.credential.values);
786+ if (missing.length > 0) {
787+ return {
788+ status: "expired",
789+ checkedAt,
790+ detail: `The connection has no credential value for ${missing.join(", ")}. Enter the API key, then try again.`,
791+ } satisfies HealthCheckResult;
792+ }
793+
794+ const auth = introspectHeadersForConnection(
795+ config,
796+ input.credential.values,
797+ input.credential.template,
798+ );
799+ const result = yield* introspect(
800+ config.endpoint,
801+ Object.keys(auth.headers).length > 0 ? auth.headers : undefined,
802+ Object.keys(auth.queryParams).length > 0 ? auth.queryParams : undefined,
803+ ).pipe(
804+ Effect.provide(input.httpClientLayer),
805+ Effect.map((introspection) => ({ ok: true as const, introspection })),
806+ Effect.catchTag("GraphqlIntrospectionError", (error) =>
807+ Effect.succeed({ ok: false as const, error }),
808+ ),
809+ );
810+
811+ if (!result.ok) return healthFromIntrospectionError(result.error, checkedAt);
812+
813+ const queryType = result.introspection.__schema.queryType?.name ?? "Query";
814+ return {
815+ status: "healthy",
816+ httpStatus: 200,
817+ identity: `GraphQL schema: ${queryType}`,
818+ checkedAt,
819+ responseSample: [{ path: "__schema.queryType.name", value: queryType }],
820+ } satisfies HealthCheckResult;
821+ });
822+
624823 // ---------------------------------------------------------------------------
625824 // Plugin extension
626825 // ---------------------------------------------------------------------------
@@ -677,13 +876,16 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 50 unmodified lines
677876 // using that connection's credential.
678877 if (input.introspectionJson === undefined) {
679878 yield* ctx.transaction(
680− ctx.core.integrations.register({
681− slug,
682− name: baseConfig.name,
683− description: input.description?.trim() || baseConfig.name,
684− config: baseConfig,
685− canRemove: true,
686− canRefresh: true,
879+ Effect.gen(function* () {
880+ yield* ctx.core.integrations.register({
881+ slug,
882+ name: baseConfig.name,
883+ description: input.description?.trim() || baseConfig.name,
884+ config: baseConfig,
885+ canRemove: true,
886+ canRefresh: true,
887+ });
888+ yield* ctx.core.integrations.setHealthCheck(slug, GRAPHQL_HEALTH_CHECK);
687889 }),
688890 );
689891 return { slug: String(slug), name: baseConfig.name, toolCount: 0 };
@@ -701,7 +903,7 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 11 unmodified lines
701903 // written OUTSIDE the transaction — re-puts are idempotent and an
702904 // aborted register leaves only an unreferenced blob), and the config
703905 // carries only its hash.
704− const snapshotJson = JSON.stringify({ data: introspection });
906+ const snapshotJson = encodeJsonString({ data: introspection });
705907 const introspectionHash = yield* sha256Hex(snapshotJson);
706908 const config = GraphqlIntegrationConfig.make({
707909 ...baseConfig,
@@ -729,6 +931,7 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 21 unmodified lines
729931 canRemove: true,
730932 canRefresh: true,
731933 });
934+ yield* ctx.core.integrations.setHealthCheck(slug, GRAPHQL_HEALTH_CHECK);
732935 }),
733936 );
734937
@@ -898,6 +1101,14 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 163 unmodified lines
8981101
8991102 describeAuthMethods: describeGraphqlAuthMethods,
9001103 describeIntegrationDisplay: describeGraphqlIntegrationDisplay,
1104+ listHealthCheckCandidates: () => Effect.succeed([GRAPHQL_HEALTH_CANDIDATE]),
1105+ checkHealth: (input) =>
1106+ checkGraphqlHealth({
1107+ integration: input.integration,
1108+ credential: input.credential,
1109+ spec: input.spec,
1110+ httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer,
1111+ }),
9011112
9021113 staticSources: (self: GraphqlPluginExtension) => [
9031114 {
@@ -975,34 +1186,59 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 71 unmodified lines
9751186 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
9761187 }) =>
9771188 Effect.gen(function* () {
978− const decoded = yield* decodeGraphqlIntegrationConfig(config).pipe(Effect.option);
979− if (Option.isNone(decoded)) return { tools: [] };
980− const graphqlConfig = decoded.value;
1189+ const graphqlConfig = yield* decodeGraphqlIntegrationConfig(config).pipe(
1190+ Effect.mapError(
1191+ (cause) =>
1192+ new StorageError({
1193+ message: "Invalid GraphQL integration config",
1194+ cause,
1195+ }),
1196+ ),
1197+ );
9811198 const introspectionJson = yield* loadIntrospectionJson(storage, graphqlConfig);
9821199 // Live introspection (no stored snapshot) needs the connection's
9831200 // credential inputs for auth-required endpoints; resolve them lazily.
984− const values =
1201+ const valuesResult =
9851202 introspectionJson == null
9861203 ? yield* getValues().pipe(
987− Effect.catch(() => Effect.succeed({} as Record<string, string | null>)),
1204+ Effect.map((values) => ({ ok: true as const, values })),
1205+ Effect.catch(() => Effect.succeed({ ok: false as const })),
9881206 )
989− : ({} as Record<string, string | null>);
990− const introspection = yield* introspectForConnection(
1207+ : { ok: true as const, values: {} as Record<string, string | null> };
1208+ if (!valuesResult.ok) {
1209+ return { tools: [] as readonly ToolDef[], incomplete: true };
1210+ }
1211+ const introspectionResult = yield* introspectForConnection(
9911212 graphqlConfig,
9921213 introspectionJson,
993− values,
1214+ valuesResult.values,
9941215 template,
9951216 options?.httpClientLayer ?? httpClientLayer,
996− ).pipe(Effect.option);
997− if (Option.isNone(introspection)) return { tools: [] };
998− const extracted = yield* extract(introspection.value).pipe(Effect.option);
999− if (Option.isNone(extracted)) return { tools: [] };
1000− const prepared = prepareOperations(extracted.value.result.fields, introspection.value);
1217+ ).pipe(
1218+ Effect.map((introspection) => ({ ok: true as const, introspection })),
1219+ Effect.catchTag("GraphqlIntrospectionError", () =>
1220+ Effect.succeed({ ok: false as const }),
1221+ ),
1222+ );
1223+ if (!introspectionResult.ok) {
1224+ return { tools: [] as readonly ToolDef[], incomplete: true };
1225+ }
1226+ const introspection = introspectionResult.introspection;
1227+ const extracted = yield* extract(introspection).pipe(
1228+ Effect.mapError(
1229+ (cause) =>
1230+ new StorageError({
1231+ message: "Failed to extract GraphQL tools from introspection schema",
1232+ cause,
1233+ }),
1234+ ),
1235+ );
1236+ const prepared = prepareOperations(extracted.result.fields, introspection);
10011237 return {
10021238 tools: buildToolDefs(prepared),
1003− definitions: extracted.value.definitions,
1239+ definitions: extracted.definitions,
10041240 };
1005− }).pipe(Effect.catch(() => Effect.succeed({ tools: [] as readonly ToolDef[] }))),
1241+ }),
10061242
10071243 // -----------------------------------------------------------------------
10081244 // Invoke one of a connection's tools. Look up the operation by integration
@@ -1169,10 +1405,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 160 unmodified lines
11691405 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
11701406 const trimmed = url.trim();
11711407 if (!trimmed) return null;
1172− const parsed = yield* Effect.try({
1173− try: () => new URL(trimmed),
1174− catch: (cause) => cause,
1175− }).pipe(Effect.option);
1408+ const parsed = parseUrlOption(trimmed);
11761409 if (Option.isNone(parsed)) return null;
11771410
11781411 const ok = yield* introspect(trimmed).pipe(
packages/react/src/components/accounts-section.tsx+10−3
@@ -95,6 +95,8 @@ function AccountRow(props: { · 94 unmodified lines
9595 const displayLabel = identity ?? String(connection.name);
9696
9797 const expired = status === "expired";
98+ const needsHealthAttention = status === "expired" || status === "degraded";
99+ const healthDetail = needsHealthAttention ? probe?.detail : undefined;
98100
99101 const handleCheck = async () => {
100102 if (checking) return;
@@ -129,9 +131,9 @@ function AccountRow(props: { · 28 unmodified lines
129131 className={`size-2 shrink-0 rounded-full ${indicator.dot}`}
130132 />
131133 <span className="truncate">{displayLabel}</span>
132− {expired ? (
133− <Badge variant="destructive" className="shrink-0">
134− Expired
134+ {needsHealthAttention ? (
135+ <Badge variant={expired ? "destructive" : "outline"} className="shrink-0">
136+ {HEALTH_STATUS_LABEL[status]}
135137 </Badge>
136138 ) : null}
137139 {needsReconsent ? (
@@ -145,6 +147,11 @@ function AccountRow(props: { · 7 unmodified lines
145147 {connection.description}
146148 </CardStackEntryDescription>
147149 ) : null}
150+ {healthDetail ? (
151+ <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
152+ {healthDetail}
153+ </CardStackEntryDescription>
154+ ) : null}
148155 {needsReconsent ? (
149156 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
150157 This connection wasn't granted all the access this integration now needs.
packages/react/src/components/add-account-modal.tsx+34−16
@@ -1769,7 +1769,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1768 unmodified lines
17691769 // Probe the pasted credential WITHOUT saving the connection. With a
17701770 // configured check the panel's Check runs this directly; with none it runs
17711771 // via handleCandidateProbe, which drafts a spec from the picked candidate.
1772− const handleValidate = async (draftSpec?: HealthCheckSpec) => {
1772+ const handleValidate = async (draftSpec?: HealthCheckSpec): Promise<HealthCheckResult | null> => {
17731773 const payloadOrigin = createCredentialPayloadOrigin({
17741774 origin: credentialOrigin,
17751775 inputs: credentialInputs,
@@ -1777,7 +1777,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1 unmodified line
17771777 onePasswordItemId,
17781778 singleInput,
17791779 });
1780− if (!method || payloadOrigin === null || validating) return;
1780+ if (!method || payloadOrigin === null || validating) return null;
17811781 setValidating(true);
17821782 const exit = await doValidate({
17831783 payload: {
@@ -1794,7 +1794,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 10 unmodified lines
17941794 if (Exit.isFailure(exit)) {
17951795 setValidationResult(null);
17961796 toast.error(messageFromExit(exit, "Couldn't check the key"));
1797− return;
1797+ return null;
17981798 }
17991799 const result = exit.value;
18001800 setValidationResult(result);
@@ -1828,21 +1828,48 @@ function AddAccountModalView(props: AddAccountModalProps) { · 27 unmodified lines
18281828 return probedIdentity;
18291829 });
18301830 }
1831+ return result;
18311832 };
18321833
18331834 // No configured check: build a draft spec from the picked candidate and
18341835 // probe with it.
1835− const handleCandidateProbe = async () => {
1836− if (!hcCandidateReady) return;
1836+ const handleCandidateProbe = async (): Promise<HealthCheckResult | null> => {
1837+ if (!hcCandidateReady) return null;
18371838 const argEntries = Object.entries(hcArgs)
18381839 .map(([key, value]) => [key, value.trim()] as const)
18391840 .filter(([, value]) => value.length > 0);
1840− await handleValidate({
1841+ return await handleValidate({
18411842 operation: hcOperation,
18421843 ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}),
18431844 });
18441845 };
18451846
1847+ const handleContinueFromValidate = async (): Promise<void> => {
1848+ if (credentialPayloadOrigin === null) {
1849+ setContinueError(singleInput ? "Enter the key first" : "Fill in every credential field");
1850+ return;
1851+ }
1852+ if (canCheckKey) {
1853+ if (!hasHealthCheck && !hcCandidateReady) {
1854+ setContinueError("Choose a check that can run before continuing");
1855+ return;
1856+ }
1857+ const result =
1858+ validationResult ??
1859+ (hasHealthCheck ? await handleValidate() : await handleCandidateProbe());
1860+ if (!result) return;
1861+ if (result.status !== "healthy") {
1862+ setContinueError(
1863+ result.detail ??
1864+ "The credential check did not return healthy. Fix the key or endpoint, then check again.",
1865+ );
1866+ return;
1867+ }
1868+ }
1869+ setContinueError(null);
1870+ setWizardStep("place");
1871+ };
1872+
18461873 // The user clicked a field in the probe's response: that field IS the
18471874 // identity. Upgrade the just-saved spec with it, and (unless hand-typed)
18481875 // adopt its value as the display name: it's the account label, live.
@@ -2819,16 +2846,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 970 unmodified lines
28192846 ) : wizardActive && wizardStep === "validate" ? (
28202847 <Button
28212848 type="button"
2822− onClick={() => {
2823− if (credentialPayloadOrigin === null) {
2824− setContinueError(
2825− singleInput ? "Enter the key first" : "Fill in every credential field",
2826− );
2827− return;
2828− }
2829− setContinueError(null);
2830− setWizardStep("place");
2831− }}
2849+ onClick={() => void handleContinueFromValidate()}
28322850 loading={validating}
28332851 >
28342852 Continue
packages/react/src/components/tool-detail.tsx+14−11
@@ -1,4 +1,4 @@
1−import { useEffect, useMemo, useRef, useState } from "react";
1+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
22 import { useAtomValue } from "@effect/atom-react";
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
44 import { toolSchemaAtom } from "../api/atoms";
@@ -503,18 +503,21 @@ function PolicyBadgeMenu(props: { · 498 unmodified lines
503503 // Empty state
504504 // ---------------------------------------------------------------------------
505505
506−export function ToolDetailEmpty(props: { hasTools: boolean }) {
506+export function ToolDetailEmpty(props: {
507+ hasTools: boolean;
508+ title?: string;
509+ description?: string;
510+ action?: ReactNode;
511+}) {
512+ const title = props.title ?? (props.hasTools ? "Select a tool" : "No tools available");
513+ const description =
514+ props.description ?? (props.hasTools ? "Choose from the list to see what it does." : null);
507515 return (
508516 <div className="flex h-full items-center justify-center">
509− <div className="text-center">
510− <p className="text-sm font-medium text-foreground">
511− {props.hasTools ? "Select a tool" : "No tools available"}
512− </p>
513− {props.hasTools && (
514− <p className="mt-1.5 text-sm text-muted-foreground">
515− Choose from the list to see what it does.
516− </p>
517− )}
517+ <div className="max-w-sm text-center">
518+ <p className="text-sm font-medium text-foreground">{title}</p>
519+ {description ? <p className="mt-1.5 text-sm text-muted-foreground">{description}</p> : null}
520+ {props.action ? <div className="mt-4">{props.action}</div> : null}
518521 </div>
519522 </div>
520523 );
packages/react/src/components/tool-tree.tsx+4−1
@@ -309,6 +309,8 @@ export function ToolTree(props: { · 308 unmodified lines
309309 * beneath each section. The same tool name can appear under two accounts (NOT
310310 * deduped). When false/unset, render one flat tree (unchanged). */
311311 groupByConnection?: boolean;
312+ /** No-search empty label. Search still uses "No tools match your filter". */
313+ emptyLabel?: string;
312314 }) {
313315 const {
314316 tools,
@@ -319,6 +321,7 @@ export function ToolTree(props: { · 4 unmodified lines
319321 patternForDisplay = toPolicyPattern,
320322 policies,
321323 groupByConnection,
324+ emptyLabel,
322325 } = props;
323326 const exactPatterns = useMemo(() => {
324327 if (!policies) return new Map<string, ToolPolicyAction>();
@@ -410,7 +413,7 @@ export function ToolTree(props: { · 85 unmodified lines
410413 <div className="min-h-0 flex-1 overflow-y-auto">
411414 {filteredTools.length === 0 ? (
412415 <div className="p-4 text-center text-xs text-muted-foreground">
413− {terms.length > 0 ? "No tools match your filter" : "No tools available"}
416+ {terms.length > 0 ? "No tools match your filter" : (emptyLabel ?? "No tools available")}
414417 </div>
415418 ) : groupByConnection ? (
416419 accountGroups.map((group) => (
packages/react/src/pages/integration-detail.tsx+83−1
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 type Owner,
1414 } from "@executor-js/sdk/shared";
1515 import {
16+ checkConnectionHealth,
1617 connectionsAllAtom,
1718 integrationToolsAllAtom,
1819 integrationsOptimisticAtom,
@@ -21,7 +22,11 @@ import { · 2 unmodified lines
2122 refreshConnection,
2223 removeIntegrationOptimistic,
2324 } from "../api/atoms";
24−import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys";
25+import {
26+ connectionCheckKeys,
27+ connectionWriteKeys,
28+ integrationWriteKeys,
29+} from "../api/reactivity-keys";
2530 import { ToolTree } from "../components/tool-tree";
2631 import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";
2732 import type { ToolSummary } from "../components/tool-tree";
@@ -36,6 +41,7 @@ import { Skeleton } from "../components/skeleton"; · 8 unmodified lines
3641 import { useExecutorDocumentTitle } from "../lib/document-title";
3742 import { ErrorState } from "../components/error-state";
3843 import { isAsyncResultLoading } from "../lib/async-result";
44+import { useConnectionsHealth } from "../lib/use-connection-health";
3945
4046 // v2: the route's `namespace` param is the integration slug. Tools belong to
4147 // the integration's per-owner connections; a tool's policy id is
@@ -66,6 +72,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 24 unmodified lines
6672 const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug));
6773 const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" });
6874 const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" });
75+ const doCheckHealth = useAtomSet(checkConnectionHealth, { mode: "promiseExit" });
6976 // Policies are owner-partitioned on write; the integration policy menu writes
7077 // Workspace (org) rules, preserving the prior default behavior.
7178 const policyActions = usePolicyActions("org");
@@ -88,6 +95,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 16 unmodified lines
8895 const [confirmDelete, setConfirmDelete] = useState(false);
8996 const [deleting, setDeleting] = useState(false);
9097 const [refreshing, setRefreshing] = useState(false);
98+ const [retryingTools, setRetryingTools] = useState(false);
9199 const [editSheetOpen, setEditSheetOpen] = useState(false);
92100 const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts");
93101 const [manualAccountHandoff, setManualAccountHandoff] =
@@ -222,6 +230,21 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 128 unmodified lines
222230 );
223231 }, [connectionsResult, slug]);
224232
233+ const healthProbeFor = useConnectionsHealth(integrationConnections);
234+ const toolsHealthIssue = useMemo(() => {
235+ const ranked = integrationConnections
236+ .map((connection) => ({ connection, probe: healthProbeFor(connection) }))
237+ .filter((entry) => entry.probe?.status === "expired" || entry.probe?.status === "degraded")
238+ .sort((a, b) => {
239+ const rank = (status: "expired" | "degraded"): number => (status === "expired" ? 0 : 1);
240+ return (
241+ rank(a.probe!.status as "expired" | "degraded") -
242+ rank(b.probe!.status as "expired" | "degraded")
243+ );
244+ });
245+ return ranked[0] ?? null;
246+ }, [healthProbeFor, integrationConnections]);
247+
225248 // Account-grouped tool rows for the Tools tab. NOT deduped across
226249 // connections: one row per (owner, connection, tool). The leaf's `name` is the
227250 // policy id `<integration>.<tool>` so leaf policy patterns stay correct, while
@@ -261,6 +284,18 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 33 unmodified lines
261284 const selection = selectedToolId ? (selectionById.get(selectedToolId) ?? null) : null;
262285 const selectedAddress = selection?.address ?? null;
263286 const selectedBareName = selection?.bareName ?? null;
287+ const connectedEmptyTools =
288+ !isBuiltInIntegration && integrationConnections.length > 0 && integrationTools.length === 0;
289+ const emptyToolsTitle = toolsHealthIssue
290+ ? "Connection needs attention"
291+ : "Checking connection health";
292+ const emptyToolsDescription =
293+ toolsHealthIssue?.probe?.detail ?? "Checking whether this connection can generate tools.";
294+ const emptyToolsListLabel = !isBuiltInIntegration
295+ ? connectedEmptyTools
296+ ? emptyToolsTitle
297+ : "No tools yet"
298+ : undefined;
264299
265300 // Declared auth methods — derived server-side from the owning plugin's config
266301 // and carried on the integration catalog response. This is authoritative even
@@ -342,6 +377,35 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 75 unmodified lines
342377 setRefreshing(false);
343378 };
344379
380+ const handleRetryToolsHealth = async () => {
381+ if (retryingTools) return;
382+ setRetryingTools(true);
383+ let refreshedAny = false;
384+ for (const connection of integrationConnections) {
385+ const health = await doCheckHealth({
386+ params: {
387+ owner: connection.owner,
388+ integration: slug,
389+ name: connection.name,
390+ },
391+ query: {},
392+ reactivityKeys: connectionCheckKeys,
393+ });
394+ if (Exit.isFailure(health) || health.value.status !== "healthy") continue;
395+ const refreshed = await doRefresh({
396+ params: {
397+ owner: connection.owner,
398+ integration: slug,
399+ name: connection.name,
400+ },
401+ reactivityKeys: connectionWriteKeys,
402+ });
403+ refreshedAny = refreshedAny || Exit.isSuccess(refreshed);
404+ }
405+ if (refreshedAny) refreshTools();
406+ setRetryingTools(false);
407+ };
408+
345409 const handleOpenAddConnection = () => {
346410 setActiveTab("accounts");
347411 setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` });
@@ -478,6 +542,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 130 unmodified lines
478542 onClearPolicy={(pattern) => void policyActions.clear(pattern)}
479543 policies={sortedPolicies}
480544 groupByConnection={!isBuiltInIntegration}
545+ emptyLabel={emptyToolsListLabel}
481546 />
482547 </div>
483548
@@ -505,6 +570,23 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 21 unmodified lines
505570 onAddConnection={handleOpenAddConnection}
506571 canAddConnection={accountsMethods.length > 0}
507572 />
573+ ) : connectedEmptyTools ? (
574+ <ToolDetailEmpty
575+ hasTools={false}
576+ title={emptyToolsTitle}
577+ description={emptyToolsDescription}
578+ action={
579+ <Button
580+ type="button"
581+ size="sm"
582+ variant="outline"
583+ onClick={() => void handleRetryToolsHealth()}
584+ disabled={retryingTools}
585+ >
586+ {retryingTools ? "Checking..." : "Check and sync tools"}
587+ </Button>
588+ }
589+ />
508590 ) : (
509591 <ToolDetailEmpty hasTools={integrationTools.length > 0} />
510592 )}