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 at1// Cross-target (browser): adding a GraphQL source whose endpoint responds with
2// connection-create time. The UI journey is the reported shape: add a GraphQL2// a non-introspection shape must fail at credential validation with actionable
3// integration, connect an API key, see the connection saved, then land on an3// copy, not save a connected account with an empty tool catalog.
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": {} }`.
9import { randomBytes } from "node:crypto";4import { randomBytes } from "node:crypto";
10import { createServer } from "node:http";5import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
11 6
12import { expect } from "@effect/vitest";7import { expect } from "@effect/vitest";
13import { Effect } from "effect";8import { Effect } from "effect";
14import { composePluginApi } from "@executor-js/api/server";9import { composePluginApi } from "@executor-js/api/server";
15import { ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";10import { ConnectionName, IntegrationSlug, type Owner } from "@executor-js/sdk/shared";
11import type { Page } from "playwright";
16 12
17import { scenario } from "../src/scenario";13import { scenario } from "../src/scenario";
18import { Api, Browser, Target } from "../src/services";14import { Api, Browser, Target } from "../src/services";
19 15
20const api = composePluginApi([] as const);16const api = composePluginApi([] as const);
17const VALID_KEY = "lin_test_key";
18const INVALID_SHAPE_COPY =
19 "The endpoint responded, but not with a GraphQL schema. Check that the URL points to a GraphQL endpoint";
21 20
22const serveInvalidIntrospectionGraphqlApi = () =>21const 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
55const closeServer = (server: ReturnType<typeof createServer>) => {
56 server.close();
57 server.closeAllConnections();
58};
59
60const serveGraphqlApi = (handler: (request: IncomingMessage, response: ServerResponse) => void) =>
23 Effect.acquireRelease(61 Effect.acquireRelease(
24 Effect.callback<{ readonly endpoint: string; readonly close: () => void }>((resume) => {62 Effect.callback<{ readonly endpoint: string; readonly close: () => void }>((resume) => {
25 const server = createServer((request, response) => {63 const server = createServer(handler);
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 });
34 server.listen(0, "127.0.0.1", () => {64 server.listen(0, "127.0.0.1", () => {
35 const address = server.address();65 const address = server.address();
36 const port = typeof address === "object" && address ? address.port : 0;66 const port = typeof address === "object" && address ? address.port : 0;
37 resume(67 resume(
38 Effect.succeed({68 Effect.succeed({
39 endpoint: `http://127.0.0.1:${port}/graphql`,69 endpoint: `http://127.0.0.1:${port}/graphql`,
40 close: () => {70 close: () => closeServer(server),
41 server.close();
42 server.closeAllConnections();
43 },
44 }),71 }),
45 );72 );
46 });73 });
@@ -48,8 +75,102 @@ const serveInvalidIntrospectionGraphqlApi = () => · 1 unmodified line
48 (server) => Effect.sync(server.close),75 (server) => Effect.sync(server.close),
49 );76 );
50 77
78const 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
89const 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
117type 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
140const 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
159const 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
51scenario(172scenario(
52 "GraphQL · invalid introspection format creates a connected account with zero tools",173 "GraphQL · invalid introspection format shows an actionable connection error",
53 {},174 {},
54 Effect.scoped(175 Effect.scoped(
55 Effect.gen(function* () {176 Effect.gen(function* () {
@@ -71,17 +192,13 @@ scenario( · 15 unmodified lines
71 });192 });
72 193
73 await step(194 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",
75 async () => {196 async () => {
76 await page197 await fillGraphqlSourceForm(page, {
77 .getByPlaceholder("https://api.example.com/graphql")198 endpoint: upstream.endpoint,
78 .fill(upstream.endpoint);199 slug,
79 await page.getByPlaceholder("e.g. Shopify API").fill("Linear GraphQL");200 name: "Linear GraphQL",
80 await page.locator("input").nth(2).fill(String(slug));201 });
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("");
85 },202 },
86 );203 );
87 204
@@ -92,60 +209,116 @@ scenario( · 4 unmodified lines
92 await page.getByText("No connections yet").waitFor();209 await page.getByText("No connections yet").waitFor();
93 });210 });
94 211
95 await step("Connect an API key", async () => {212 await step("Try to connect an API key and see the schema-format error", async () => {
96 await page.getByRole("button", { name: "Add connection" }).first().click();213 await page.getByRole("button", { name: "Add connection" }).first().click();
97 const dialog = page.getByRole("dialog");214 const dialog = page.getByRole("dialog");
98 await dialog.getByRole("tab", { name: "API key (Authorization)" }).waitFor();215 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);
100 await dialog.getByRole("button", { name: "Continue" }).click();217 await dialog.getByRole("button", { name: "Continue" }).click();
101 await dialog.getByLabel("Display name").fill("main");218 await dialog.getByRole("alert").getByText(INVALID_SHAPE_COPY).waitFor({
102 await dialog.getByRole("button", { name: "Add connection" }).click();219 timeout: 30_000,
103 await page.getByText("Connection added").waitFor({ timeout: 30_000 });220 });
104 await page.getByText("main").waitFor();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();
105 });224 });
106 225
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 () => {
108 await page.getByRole("tab", { name: "Tools" }).click();227 await page.getByRole("tab", { name: "Tools" }).click();
109 await page.getByPlaceholder("Filter 0 tools…").waitFor({ timeout: 30_000 });228 await page.getByText("No tools yet").first().waitFor({ timeout: 30_000 });
110 await page.getByText("No tools available").first().waitFor();229 expect(await page.getByText("No tools available").count()).toBe(0);
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);
121 });230 });
122 });231 });
123 232
124 const connections = yield* apiClient.connections.list({233 const connections = yield* apiClient.connections.list({
125 query: { integration: slug },234 query: { integration: slug },
126 });235 });
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);
128 240
129 const tools = yield* apiClient.tools.list({ query: { integration: slug } });241 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([]);
131 }),243 }),
244 cleanupIntegration(apiClient, slug),
245 );
246 }),
247 ),
248);
249
250scenario(
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(
132 Effect.gen(function* () {265 Effect.gen(function* () {
133 const connections = yield* apiClient.connections266 yield* browser.session(identity, async ({ page, step }) => {
134 .list({ query: { integration: slug } })267 await step("Open the Add GraphQL integration form", async () => {
135 .pipe(Effect.catch(() => Effect.succeed([])));268 await page.goto("/integrations/add/graphql", { waitUntil: "networkidle" });
136 for (const connection of connections) {269 await page.getByRole("heading", { name: "Add GraphQL integration" }).waitFor();
137 yield* apiClient.connections270 });
138 .remove({271
139 params: {272 await step(
140 owner: connection.owner,273 "Enter a valid GraphQL endpoint with Authorization API key auth",
141 integration: slug,274 async () => {
142 name: ConnectionName.make(String(connection.name)),275 await fillGraphqlSourceForm(page, {
143 },276 endpoint: upstream.endpoint,
144 })277 slug,
145 .pipe(Effect.ignore);278 name: "Healthy GraphQL",
146 }279 });
147 yield* apiClient.integrations.remove({ params: { slug } }).pipe(Effect.ignore);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);
148 }),320 }),
321 cleanupIntegration(apiClient, slug),
149 );322 );
150 }),323 }),
151 ),324 ),
packages/plugins/graphql/src/sdk/errors.ts+12−0
@@ -6,6 +6,18 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass<GraphqlIn · 5 unmodified lines
6 "GraphqlIntrospectionError",6 "GraphqlIntrospectionError",
7 {7 {
8 message: Schema.String,8 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),
9 },21 },
10) {}22) {}
11 23
packages/plugins/graphql/src/sdk/introspect.ts+14−1
@@ -267,6 +267,7 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 266 unmodified lines
267 () =>267 () =>
268 new GraphqlIntrospectionError({268 new GraphqlIntrospectionError({
269 message: "Failed to reach GraphQL endpoint",269 message: "Failed to reach GraphQL endpoint",
270 reason: "network",
270 }),271 }),
271 ),272 ),
272 );273 );
@@ -285,6 +286,9 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 12 unmodified lines
285 message: upstreamMessage286 message: upstreamMessage
286 ? `Introspection failed with status ${response.status}: ${upstreamMessage}`287 ? `Introspection failed with status ${response.status}: ${upstreamMessage}`
287 : `Introspection failed with status ${response.status}`,288 : `Introspection failed with status ${response.status}`,
289 status: response.status,
290 reason: "http",
291 ...(upstreamMessage ? { upstreamMessage } : {}),
288 });292 });
289 }293 }
290 294
@@ -294,6 +298,8 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 3 unmodified lines
294 () =>298 () =>
295 new GraphqlIntrospectionError({299 new GraphqlIntrospectionError({
296 message: `Failed to parse introspection response as JSON`,300 message: `Failed to parse introspection response as JSON`,
301 status: response.status,
302 reason: "invalid-json",
297 }),303 }),
298 ),304 ),
299 );305 );
@@ -303,22 +309,29 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 3 unmodified lines
303 () =>309 () =>
304 new GraphqlIntrospectionError({310 new GraphqlIntrospectionError({
305 message: "Introspection response has an invalid shape",311 message: "Introspection response has an invalid shape",
312 status: response.status,
313 reason: "invalid-shape",
306 }),314 }),
307 ),315 ),
308 );316 );
309 317
310 if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) {318 if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) {
311 const upstreamMessage = firstUpstreamErrorMessage(json);319 const upstreamMessage = upstreamTextMessage(firstUpstreamErrorMessage(json) ?? "");
312 return yield* new GraphqlIntrospectionError({320 return yield* new GraphqlIntrospectionError({
313 message: upstreamMessage321 message: upstreamMessage
314 ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}`322 ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}`
315 : `Introspection returned ${json.errors.length} error(s)`,323 : `Introspection returned ${json.errors.length} error(s)`,
324 status: response.status,
325 reason: "graphql-errors",
326 ...(upstreamMessage ? { upstreamMessage } : {}),
316 });327 });
317 }328 }
318 329
319 if (!json.data?.__schema) {330 if (!json.data?.__schema) {
320 return yield* new GraphqlIntrospectionError({331 return yield* new GraphqlIntrospectionError({
321 message: "Introspection response missing __schema",332 message: "Introspection response missing __schema",
333 status: response.status,
334 reason: "missing-schema",
322 });335 });
323 }336 }
324 337
packages/plugins/graphql/src/sdk/plugin.test.ts+194−0
@@ -567,6 +567,200 @@ describe("graphqlPlugin real protocol server", () => { · 566 unmodified lines
567 expect(names).toContain("mutation.setGreeting");567 expect(names).toContain("mutation.setGreeting");
568 }),568 }),
569 );569 );
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 );
570});764});
571 765
572describe("graphqlPlugin", () => {766describe("graphqlPlugin", () => {
packages/plugins/graphql/src/sdk/plugin.ts+270−37
@@ -11,9 +11,12 @@ import { · 10 unmodified lines
11 IntegrationSlug,11 IntegrationSlug,
12 mergeAuthTemplates,12 mergeAuthTemplates,
13 sha256Hex,13 sha256Hex,
14 StorageError,
14 ToolName,15 ToolName,
15 ToolResult,16 ToolResult,
16 type AuthMethodDescriptor,17 type AuthMethodDescriptor,
18 type HealthCheckCandidate,
19 type HealthCheckResult,
17 type IntegrationConfig,20 type IntegrationConfig,
18 type IntegrationRecord,21 type IntegrationRecord,
19 type PluginCtx,22 type PluginCtx,
@@ -44,6 +47,7 @@ import { · 24 unmodified lines
44import { extract } from "./extract";47import { extract } from "./extract";
45import {48import {
46 GraphqlAuthRequiredError,49 GraphqlAuthRequiredError,
50 GraphqlExtractionError,
47 GraphqlIntrospectionError,51 GraphqlIntrospectionError,
48 GraphqlInvocationError,52 GraphqlInvocationError,
49} from "./errors";53} from "./errors";
@@ -76,12 +80,132 @@ const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody); · 26 unmodified lines
76const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined =>80const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined =>
77 Option.getOrUndefined(decodeGraphqlErrorsBody(errors));81 Option.getOrUndefined(decodeGraphqlErrorsBody(errors));
78 82
83const encodeJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
84const parseUrlOption = Option.liftThrowable((value: string) => new URL(value));
85
79const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined =>86const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined =>
80 errors87 errors
81 .map((error) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message)88 .map((error) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message)
82 .find((message) => message !== undefined && message.length > 0);89 .find((message) => message !== undefined && message.length > 0);
83 90
84const GRAPHQL_PLUGIN_ID = "graphql";91const GRAPHQL_PLUGIN_ID = "graphql";
92const GRAPHQL_HEALTH_OPERATION = "__schema";
93const GRAPHQL_HEALTH_CHECK = { operation: GRAPHQL_HEALTH_OPERATION };
94const 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};
101const 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).";
103const 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.";
105const GRAPHQL_NETWORK_DETAIL =
106 "We couldn't reach the GraphQL endpoint. Check that the URL is correct and reachable, then try again.";
107const GRAPHQL_CONFIG_DETAIL =
108 "The GraphQL integration config is invalid. Edit the endpoint and auth method, then try again.";
109
110const 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
116const appendUpstreamMessage = (detail: string, message?: string): string =>
117 message && message.trim().length > 0
118 ? `${detail} Upstream said: ${truncateHealthDetail(message)}`
119 : detail;
120
121const 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
127const 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
140const 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};
85 209
86// ---------------------------------------------------------------------------210// ---------------------------------------------------------------------------
87// Extension input shapes211// Extension input shapes
@@ -538,7 +662,10 @@ const materializeOperations = ( · 450 unmodified lines
538 readonly values: Record<string, string | null>;662 readonly values: Record<string, string | null>;
539 },663 },
540 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,664 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
541): Effect.Effect<readonly StoredOperation[], GraphqlIntrospectionError | StorageFailure> =>665): Effect.Effect<
666 readonly StoredOperation[],
667 GraphqlIntrospectionError | GraphqlExtractionError | StorageFailure
668> =>
542 Effect.gen(function* () {669 Effect.gen(function* () {
543 // Render the exact method this connection references (we have its slug670 // Render the exact method this connection references (we have its slug
544 // here, unlike `resolveTools`) so an auth-required endpoint introspects.671 // here, unlike `resolveTools`) so an auth-required endpoint introspects.
@@ -565,15 +692,7 @@ const materializeOperations = ( · 20 unmodified lines
565 Object.keys(queryParams).length > 0 ? queryParams : undefined,692 Object.keys(queryParams).length > 0 ? queryParams : undefined,
566 ).pipe(Effect.provide(httpClientLayer));693 ).pipe(Effect.provide(httpClientLayer));
567 694
568 const { result } = yield* extract(introspection).pipe(695 const { result } = yield* extract(introspection);
569 Effect.catch(() =>
570 Effect.succeed({
571 result: { fields: [] as readonly ExtractedField[] },
572 } as {
573 readonly result: { readonly fields: readonly ExtractedField[] };
574 }),
575 ),
576 );
577 const prepared = prepareOperations(result.fields, introspection);696 const prepared = prepareOperations(result.fields, introspection);
578 const stored = toStoredOperations(IntegrationSlug.make(integration), prepared);697 const stored = toStoredOperations(IntegrationSlug.make(integration), prepared);
579 yield* ctx.storage.replaceOperations(integration, stored);698 yield* ctx.storage.replaceOperations(integration, stored);
@@ -621,6 +740,86 @@ export const describeGraphqlIntegrationDisplay = ( · 41 unmodified lines
621 return { url: config?.endpoint };740 return { url: config?.endpoint };
622};741};
623 742
743const 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
624// ---------------------------------------------------------------------------823// ---------------------------------------------------------------------------
625// Plugin extension824// Plugin extension
626// ---------------------------------------------------------------------------825// ---------------------------------------------------------------------------
@@ -677,13 +876,16 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 50 unmodified lines
677 // using that connection's credential.876 // using that connection's credential.
678 if (input.introspectionJson === undefined) {877 if (input.introspectionJson === undefined) {
679 yield* ctx.transaction(878 yield* ctx.transaction(
680 ctx.core.integrations.register({879 Effect.gen(function* () {
681 slug,880 yield* ctx.core.integrations.register({
682 name: baseConfig.name,881 slug,
683 description: input.description?.trim() || baseConfig.name,882 name: baseConfig.name,
684 config: baseConfig,883 description: input.description?.trim() || baseConfig.name,
685 canRemove: true,884 config: baseConfig,
686 canRefresh: true,885 canRemove: true,
886 canRefresh: true,
887 });
888 yield* ctx.core.integrations.setHealthCheck(slug, GRAPHQL_HEALTH_CHECK);
687 }),889 }),
688 );890 );
689 return { slug: String(slug), name: baseConfig.name, toolCount: 0 };891 return { slug: String(slug), name: baseConfig.name, toolCount: 0 };
@@ -701,7 +903,7 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 11 unmodified lines
701 // written OUTSIDE the transaction — re-puts are idempotent and an903 // written OUTSIDE the transaction — re-puts are idempotent and an
702 // aborted register leaves only an unreferenced blob), and the config904 // aborted register leaves only an unreferenced blob), and the config
703 // carries only its hash.905 // carries only its hash.
704 const snapshotJson = JSON.stringify({ data: introspection });906 const snapshotJson = encodeJsonString({ data: introspection });
705 const introspectionHash = yield* sha256Hex(snapshotJson);907 const introspectionHash = yield* sha256Hex(snapshotJson);
706 const config = GraphqlIntegrationConfig.make({908 const config = GraphqlIntegrationConfig.make({
707 ...baseConfig,909 ...baseConfig,
@@ -729,6 +931,7 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 21 unmodified lines
729 canRemove: true,931 canRemove: true,
730 canRefresh: true,932 canRefresh: true,
731 });933 });
934 yield* ctx.core.integrations.setHealthCheck(slug, GRAPHQL_HEALTH_CHECK);
732 }),935 }),
733 );936 );
734 937
@@ -898,6 +1101,14 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 163 unmodified lines
898 1101
899 describeAuthMethods: describeGraphqlAuthMethods,1102 describeAuthMethods: describeGraphqlAuthMethods,
900 describeIntegrationDisplay: describeGraphqlIntegrationDisplay,1103 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 }),
901 1112
902 staticSources: (self: GraphqlPluginExtension) => [1113 staticSources: (self: GraphqlPluginExtension) => [
903 {1114 {
@@ -975,34 +1186,59 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 71 unmodified lines
975 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;1186 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
976 }) =>1187 }) =>
977 Effect.gen(function* () {1188 Effect.gen(function* () {
978 const decoded = yield* decodeGraphqlIntegrationConfig(config).pipe(Effect.option);1189 const graphqlConfig = yield* decodeGraphqlIntegrationConfig(config).pipe(
979 if (Option.isNone(decoded)) return { tools: [] };1190 Effect.mapError(
980 const graphqlConfig = decoded.value;1191 (cause) =>
1192 new StorageError({
1193 message: "Invalid GraphQL integration config",
1194 cause,
1195 }),
1196 ),
1197 );
981 const introspectionJson = yield* loadIntrospectionJson(storage, graphqlConfig);1198 const introspectionJson = yield* loadIntrospectionJson(storage, graphqlConfig);
982 // Live introspection (no stored snapshot) needs the connection's1199 // Live introspection (no stored snapshot) needs the connection's
983 // credential inputs for auth-required endpoints; resolve them lazily.1200 // credential inputs for auth-required endpoints; resolve them lazily.
984 const values =1201 const valuesResult =
985 introspectionJson == null1202 introspectionJson == null
986 ? yield* getValues().pipe(1203 ? 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 })),
988 )1206 )
989 : ({} as Record<string, string | null>);1207 : { ok: true as const, values: {} as Record<string, string | null> };
990 const introspection = yield* introspectForConnection(1208 if (!valuesResult.ok) {
1209 return { tools: [] as readonly ToolDef[], incomplete: true };
1210 }
1211 const introspectionResult = yield* introspectForConnection(
991 graphqlConfig,1212 graphqlConfig,
992 introspectionJson,1213 introspectionJson,
993 values,1214 valuesResult.values,
994 template,1215 template,
995 options?.httpClientLayer ?? httpClientLayer,1216 options?.httpClientLayer ?? httpClientLayer,
996 ).pipe(Effect.option);1217 ).pipe(
997 if (Option.isNone(introspection)) return { tools: [] };1218 Effect.map((introspection) => ({ ok: true as const, introspection })),
998 const extracted = yield* extract(introspection.value).pipe(Effect.option);1219 Effect.catchTag("GraphqlIntrospectionError", () =>
999 if (Option.isNone(extracted)) return { tools: [] };1220 Effect.succeed({ ok: false as const }),
1000 const prepared = prepareOperations(extracted.value.result.fields, introspection.value);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);
1001 return {1237 return {
1002 tools: buildToolDefs(prepared),1238 tools: buildToolDefs(prepared),
1003 definitions: extracted.value.definitions,1239 definitions: extracted.definitions,
1004 };1240 };
1005 }).pipe(Effect.catch(() => Effect.succeed({ tools: [] as readonly ToolDef[] }))),1241 }),
1006 1242
1007 // -----------------------------------------------------------------------1243 // -----------------------------------------------------------------------
1008 // Invoke one of a connection's tools. Look up the operation by integration1244 // 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
1169 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;1405 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
1170 const trimmed = url.trim();1406 const trimmed = url.trim();
1171 if (!trimmed) return null;1407 if (!trimmed) return null;
1172 const parsed = yield* Effect.try({1408 const parsed = parseUrlOption(trimmed);
1173 try: () => new URL(trimmed),
1174 catch: (cause) => cause,
1175 }).pipe(Effect.option);
1176 if (Option.isNone(parsed)) return null;1409 if (Option.isNone(parsed)) return null;
1177 1410
1178 const ok = yield* introspect(trimmed).pipe(1411 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
95 const displayLabel = identity ?? String(connection.name);95 const displayLabel = identity ?? String(connection.name);
96 96
97 const expired = status === "expired";97 const expired = status === "expired";
98 const needsHealthAttention = status === "expired" || status === "degraded";
99 const healthDetail = needsHealthAttention ? probe?.detail : undefined;
98 100
99 const handleCheck = async () => {101 const handleCheck = async () => {
100 if (checking) return;102 if (checking) return;
@@ -129,9 +131,9 @@ function AccountRow(props: { · 28 unmodified lines
129 className={`size-2 shrink-0 rounded-full ${indicator.dot}`}131 className={`size-2 shrink-0 rounded-full ${indicator.dot}`}
130 />132 />
131 <span className="truncate">{displayLabel}</span>133 <span className="truncate">{displayLabel}</span>
132 {expired ? (134 {needsHealthAttention ? (
133 <Badge variant="destructive" className="shrink-0">135 <Badge variant={expired ? "destructive" : "outline"} className="shrink-0">
134 Expired136 {HEALTH_STATUS_LABEL[status]}
135 </Badge>137 </Badge>
136 ) : null}138 ) : null}
137 {needsReconsent ? (139 {needsReconsent ? (
@@ -145,6 +147,11 @@ function AccountRow(props: { · 7 unmodified lines
145 {connection.description}147 {connection.description}
146 </CardStackEntryDescription>148 </CardStackEntryDescription>
147 ) : null}149 ) : null}
150 {healthDetail ? (
151 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
152 {healthDetail}
153 </CardStackEntryDescription>
154 ) : null}
148 {needsReconsent ? (155 {needsReconsent ? (
149 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">156 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
150 This connection wasn't granted all the access this integration now needs.157 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
1769 // Probe the pasted credential WITHOUT saving the connection. With a1769 // Probe the pasted credential WITHOUT saving the connection. With a
1770 // configured check the panel's Check runs this directly; with none it runs1770 // configured check the panel's Check runs this directly; with none it runs
1771 // via handleCandidateProbe, which drafts a spec from the picked candidate.1771 // 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> => {
1773 const payloadOrigin = createCredentialPayloadOrigin({1773 const payloadOrigin = createCredentialPayloadOrigin({
1774 origin: credentialOrigin,1774 origin: credentialOrigin,
1775 inputs: credentialInputs,1775 inputs: credentialInputs,
@@ -1777,7 +1777,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1 unmodified line
1777 onePasswordItemId,1777 onePasswordItemId,
1778 singleInput,1778 singleInput,
1779 });1779 });
1780 if (!method || payloadOrigin === null || validating) return;1780 if (!method || payloadOrigin === null || validating) return null;
1781 setValidating(true);1781 setValidating(true);
1782 const exit = await doValidate({1782 const exit = await doValidate({
1783 payload: {1783 payload: {
@@ -1794,7 +1794,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 10 unmodified lines
1794 if (Exit.isFailure(exit)) {1794 if (Exit.isFailure(exit)) {
1795 setValidationResult(null);1795 setValidationResult(null);
1796 toast.error(messageFromExit(exit, "Couldn't check the key"));1796 toast.error(messageFromExit(exit, "Couldn't check the key"));
1797 return;1797 return null;
1798 }1798 }
1799 const result = exit.value;1799 const result = exit.value;
1800 setValidationResult(result);1800 setValidationResult(result);
@@ -1828,21 +1828,48 @@ function AddAccountModalView(props: AddAccountModalProps) { · 27 unmodified lines
1828 return probedIdentity;1828 return probedIdentity;
1829 });1829 });
1830 }1830 }
1831 return result;
1831 };1832 };
1832 1833
1833 // No configured check: build a draft spec from the picked candidate and1834 // No configured check: build a draft spec from the picked candidate and
1834 // probe with it.1835 // probe with it.
1835 const handleCandidateProbe = async () => {1836 const handleCandidateProbe = async (): Promise<HealthCheckResult | null> => {
1836 if (!hcCandidateReady) return;1837 if (!hcCandidateReady) return null;
1837 const argEntries = Object.entries(hcArgs)1838 const argEntries = Object.entries(hcArgs)
1838 .map(([key, value]) => [key, value.trim()] as const)1839 .map(([key, value]) => [key, value.trim()] as const)
1839 .filter(([, value]) => value.length > 0);1840 .filter(([, value]) => value.length > 0);
1840 await handleValidate({1841 return await handleValidate({
1841 operation: hcOperation,1842 operation: hcOperation,
1842 ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}),1843 ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}),
1843 });1844 });
1844 };1845 };
1845 1846
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
1846 // The user clicked a field in the probe's response: that field IS the1873 // The user clicked a field in the probe's response: that field IS the
1847 // identity. Upgrade the just-saved spec with it, and (unless hand-typed)1874 // identity. Upgrade the just-saved spec with it, and (unless hand-typed)
1848 // adopt its value as the display name: it's the account label, live.1875 // adopt its value as the display name: it's the account label, live.
@@ -2819,16 +2846,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 970 unmodified lines
2819 ) : wizardActive && wizardStep === "validate" ? (2846 ) : wizardActive && wizardStep === "validate" ? (
2820 <Button2847 <Button
2821 type="button"2848 type="button"
2822 onClick={() => {2849 onClick={() => void handleContinueFromValidate()}
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 }}
2832 loading={validating}2850 loading={validating}
2833 >2851 >
2834 Continue2852 Continue
packages/react/src/components/tool-detail.tsx+14−11
@@ -1,4 +1,4 @@
1import { useEffect, useMemo, useRef, useState } from "react";1import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { toolSchemaAtom } from "../api/atoms";4import { toolSchemaAtom } from "../api/atoms";
@@ -503,18 +503,21 @@ function PolicyBadgeMenu(props: { · 498 unmodified lines
503// Empty state503// Empty state
504// ---------------------------------------------------------------------------504// ---------------------------------------------------------------------------
505 505
506export function ToolDetailEmpty(props: { hasTools: boolean }) {506export 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);
507 return (515 return (
508 <div className="flex h-full items-center justify-center">516 <div className="flex h-full items-center justify-center">
509 <div className="text-center">517 <div className="max-w-sm text-center">
510 <p className="text-sm font-medium text-foreground">518 <p className="text-sm font-medium text-foreground">{title}</p>
511 {props.hasTools ? "Select a tool" : "No tools available"}519 {description ? <p className="mt-1.5 text-sm text-muted-foreground">{description}</p> : null}
512 </p>520 {props.action ? <div className="mt-4">{props.action}</div> : null}
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 )}
518 </div>521 </div>
519 </div>522 </div>
520 );523 );
packages/react/src/components/tool-tree.tsx+4−1
@@ -309,6 +309,8 @@ export function ToolTree(props: { · 308 unmodified lines
309 * beneath each section. The same tool name can appear under two accounts (NOT309 * beneath each section. The same tool name can appear under two accounts (NOT
310 * deduped). When false/unset, render one flat tree (unchanged). */310 * deduped). When false/unset, render one flat tree (unchanged). */
311 groupByConnection?: boolean;311 groupByConnection?: boolean;
312 /** No-search empty label. Search still uses "No tools match your filter". */
313 emptyLabel?: string;
312}) {314}) {
313 const {315 const {
314 tools,316 tools,
@@ -319,6 +321,7 @@ export function ToolTree(props: { · 4 unmodified lines
319 patternForDisplay = toPolicyPattern,321 patternForDisplay = toPolicyPattern,
320 policies,322 policies,
321 groupByConnection,323 groupByConnection,
324 emptyLabel,
322 } = props;325 } = props;
323 const exactPatterns = useMemo(() => {326 const exactPatterns = useMemo(() => {
324 if (!policies) return new Map<string, ToolPolicyAction>();327 if (!policies) return new Map<string, ToolPolicyAction>();
@@ -410,7 +413,7 @@ export function ToolTree(props: { · 85 unmodified lines
410 <div className="min-h-0 flex-1 overflow-y-auto">413 <div className="min-h-0 flex-1 overflow-y-auto">
411 {filteredTools.length === 0 ? (414 {filteredTools.length === 0 ? (
412 <div className="p-4 text-center text-xs text-muted-foreground">415 <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")}
414 </div>417 </div>
415 ) : groupByConnection ? (418 ) : groupByConnection ? (
416 accountGroups.map((group) => (419 accountGroups.map((group) => (
packages/react/src/pages/integration-detail.tsx+83−1
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13 type Owner,13 type Owner,
14} from "@executor-js/sdk/shared";14} from "@executor-js/sdk/shared";
15import {15import {
16 checkConnectionHealth,
16 connectionsAllAtom,17 connectionsAllAtom,
17 integrationToolsAllAtom,18 integrationToolsAllAtom,
18 integrationsOptimisticAtom,19 integrationsOptimisticAtom,
@@ -21,7 +22,11 @@ import { · 2 unmodified lines
21 refreshConnection,22 refreshConnection,
22 removeIntegrationOptimistic,23 removeIntegrationOptimistic,
23} from "../api/atoms";24} from "../api/atoms";
24import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys";25import {
26 connectionCheckKeys,
27 connectionWriteKeys,
28 integrationWriteKeys,
29} from "../api/reactivity-keys";
25import { ToolTree } from "../components/tool-tree";30import { ToolTree } from "../components/tool-tree";
26import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";31import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";
27import type { ToolSummary } from "../components/tool-tree";32import type { ToolSummary } from "../components/tool-tree";
@@ -36,6 +41,7 @@ import { Skeleton } from "../components/skeleton"; · 8 unmodified lines
36import { useExecutorDocumentTitle } from "../lib/document-title";41import { useExecutorDocumentTitle } from "../lib/document-title";
37import { ErrorState } from "../components/error-state";42import { ErrorState } from "../components/error-state";
38import { isAsyncResultLoading } from "../lib/async-result";43import { isAsyncResultLoading } from "../lib/async-result";
44import { useConnectionsHealth } from "../lib/use-connection-health";
39 45
40// v2: the route's `namespace` param is the integration slug. Tools belong to46// v2: the route's `namespace` param is the integration slug. Tools belong to
41// the integration's per-owner connections; a tool's policy id is47// 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
66 const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug));72 const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug));
67 const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" });73 const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" });
68 const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" });74 const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" });
75 const doCheckHealth = useAtomSet(checkConnectionHealth, { mode: "promiseExit" });
69 // Policies are owner-partitioned on write; the integration policy menu writes76 // Policies are owner-partitioned on write; the integration policy menu writes
70 // Workspace (org) rules, preserving the prior default behavior.77 // Workspace (org) rules, preserving the prior default behavior.
71 const policyActions = usePolicyActions("org");78 const policyActions = usePolicyActions("org");
@@ -88,6 +95,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 16 unmodified lines
88 const [confirmDelete, setConfirmDelete] = useState(false);95 const [confirmDelete, setConfirmDelete] = useState(false);
89 const [deleting, setDeleting] = useState(false);96 const [deleting, setDeleting] = useState(false);
90 const [refreshing, setRefreshing] = useState(false);97 const [refreshing, setRefreshing] = useState(false);
98 const [retryingTools, setRetryingTools] = useState(false);
91 const [editSheetOpen, setEditSheetOpen] = useState(false);99 const [editSheetOpen, setEditSheetOpen] = useState(false);
92 const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts");100 const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts");
93 const [manualAccountHandoff, setManualAccountHandoff] =101 const [manualAccountHandoff, setManualAccountHandoff] =
@@ -222,6 +230,21 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 128 unmodified lines
222 );230 );
223 }, [connectionsResult, slug]);231 }, [connectionsResult, slug]);
224 232
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
225 // Account-grouped tool rows for the Tools tab. NOT deduped across248 // Account-grouped tool rows for the Tools tab. NOT deduped across
226 // connections: one row per (owner, connection, tool). The leaf's `name` is the249 // connections: one row per (owner, connection, tool). The leaf's `name` is the
227 // policy id `<integration>.<tool>` so leaf policy patterns stay correct, while250 // policy id `<integration>.<tool>` so leaf policy patterns stay correct, while
@@ -261,6 +284,18 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 33 unmodified lines
261 const selection = selectedToolId ? (selectionById.get(selectedToolId) ?? null) : null;284 const selection = selectedToolId ? (selectionById.get(selectedToolId) ?? null) : null;
262 const selectedAddress = selection?.address ?? null;285 const selectedAddress = selection?.address ?? null;
263 const selectedBareName = selection?.bareName ?? null;286 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;
264 299
265 // Declared auth methods — derived server-side from the owning plugin's config300 // Declared auth methods — derived server-side from the owning plugin's config
266 // and carried on the integration catalog response. This is authoritative even301 // and carried on the integration catalog response. This is authoritative even
@@ -342,6 +377,35 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 75 unmodified lines
342 setRefreshing(false);377 setRefreshing(false);
343 };378 };
344 379
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
345 const handleOpenAddConnection = () => {409 const handleOpenAddConnection = () => {
346 setActiveTab("accounts");410 setActiveTab("accounts");
347 setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` });411 setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` });
@@ -478,6 +542,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 130 unmodified lines
478 onClearPolicy={(pattern) => void policyActions.clear(pattern)}542 onClearPolicy={(pattern) => void policyActions.clear(pattern)}
479 policies={sortedPolicies}543 policies={sortedPolicies}
480 groupByConnection={!isBuiltInIntegration}544 groupByConnection={!isBuiltInIntegration}
545 emptyLabel={emptyToolsListLabel}
481 />546 />
482 </div>547 </div>
483 548
@@ -505,6 +570,23 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 21 unmodified lines
505 onAddConnection={handleOpenAddConnection}570 onAddConnection={handleOpenAddConnection}
506 canAddConnection={accountsMethods.length > 0}571 canAddConnection={accountsMethods.length > 0}
507 />572 />
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 />
508 ) : (590 ) : (
509 <ToolDetailEmpty hasTools={integrationTools.length > 0} />591 <ToolDetailEmpty hasTools={integrationTools.length > 0} />
510 )}592 )}