Unified · Split
| @@ -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. | |
| 9 | 4 | import { randomBytes } from "node:crypto"; |
| 10 | −import { createServer } from "node:http"; | |
| 5 | +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | |
| 11 | 6 | |
| 12 | 7 | import { expect } from "@effect/vitest"; |
| 13 | 8 | import { Effect } from "effect"; |
| 14 | 9 | 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"; | |
| 16 | 12 | |
| 17 | 13 | import { scenario } from "../src/scenario"; |
| 18 | 14 | import { Api, Browser, Target } from "../src/services"; |
| 19 | 15 | |
| 20 | 16 | 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"; | |
| 21 | 20 | |
| 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) => | |
| 23 | 61 | Effect.acquireRelease( |
| 24 | 62 | 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); | |
| 34 | 64 | server.listen(0, "127.0.0.1", () => { |
| 35 | 65 | const address = server.address(); |
| 36 | 66 | const port = typeof address === "object" && address ? address.port : 0; |
| 37 | 67 | resume( |
| 38 | 68 | Effect.succeed({ |
| 39 | 69 | endpoint: `http://127.0.0.1:${port}/graphql`, |
| 40 | − close: () => { | |
| 41 | − server.close(); | |
| 42 | − server.closeAllConnections(); | |
| 43 | − }, | |
| 70 | + close: () => closeServer(server), | |
| 44 | 71 | }), |
| 45 | 72 | ); |
| 46 | 73 | }); |
| @@ -48,8 +75,102 @@ const serveInvalidIntrospectionGraphqlApi = () => · 1 unmodified line | ||
| 48 | 75 | (server) => Effect.sync(server.close), |
| 49 | 76 | ); |
| 50 | 77 | |
| 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 | + | |
| 51 | 172 | scenario( |
| 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 | 175 | Effect.scoped( |
| 55 | 176 | Effect.gen(function* () { |
| @@ -71,17 +192,13 @@ scenario( · 15 unmodified lines | ||
| 71 | 192 | }); |
| 72 | 193 | |
| 73 | 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 | 196 | 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 | + }); | |
| 85 | 202 | }, |
| 86 | 203 | ); |
| 87 | 204 | |
| @@ -92,60 +209,116 @@ scenario( · 4 unmodified lines | ||
| 92 | 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 | 213 | await page.getByRole("button", { name: "Add connection" }).first().click(); |
| 97 | 214 | const dialog = page.getByRole("dialog"); |
| 98 | 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 | 217 | 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(); | |
| 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 | 227 | 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); | |
| 121 | 230 | }); |
| 122 | 231 | }); |
| 123 | 232 | |
| 124 | 233 | const connections = yield* apiClient.connections.list({ |
| 125 | 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 | 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 | + | |
| 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( | |
| 132 | 265 | 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); | |
| 148 | 320 | }), |
| 321 | + cleanupIntegration(apiClient, slug), | |
| 149 | 322 | ); |
| 150 | 323 | }), |
| 151 | 324 | ), |
| @@ -6,6 +6,18 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass<GraphqlIn · 5 unmodified lines | ||
| 6 | 6 | "GraphqlIntrospectionError", |
| 7 | 7 | { |
| 8 | 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 | |
| @@ -267,6 +267,7 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 266 unmodified lines | ||
| 267 | 267 | () => |
| 268 | 268 | new GraphqlIntrospectionError({ |
| 269 | 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 | 286 | message: upstreamMessage |
| 286 | 287 | ? `Introspection failed with status ${response.status}: ${upstreamMessage}` |
| 287 | 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 | 299 | new GraphqlIntrospectionError({ |
| 296 | 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 | 310 | new GraphqlIntrospectionError({ |
| 305 | 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 | 318 | if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) { |
| 311 | − const upstreamMessage = firstUpstreamErrorMessage(json); | |
| 319 | + const upstreamMessage = upstreamTextMessage(firstUpstreamErrorMessage(json) ?? ""); | |
| 312 | 320 | return yield* new GraphqlIntrospectionError({ |
| 313 | 321 | message: upstreamMessage |
| 314 | 322 | ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}` |
| 315 | 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 | 330 | if (!json.data?.__schema) { |
| 320 | 331 | return yield* new GraphqlIntrospectionError({ |
| 321 | 332 | message: "Introspection response missing __schema", |
| 333 | + status: response.status, | |
| 334 | + reason: "missing-schema", | |
| 322 | 335 | }); |
| 323 | 336 | } |
| 324 | 337 | |
| @@ -567,6 +567,200 @@ describe("graphqlPlugin real protocol server", () => { · 566 unmodified lines | ||
| 567 | 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 | |
| 572 | 766 | describe("graphqlPlugin", () => { |
| @@ -11,9 +11,12 @@ import { · 10 unmodified lines | ||
| 11 | 11 | IntegrationSlug, |
| 12 | 12 | mergeAuthTemplates, |
| 13 | 13 | sha256Hex, |
| 14 | + StorageError, | |
| 14 | 15 | ToolName, |
| 15 | 16 | ToolResult, |
| 16 | 17 | type AuthMethodDescriptor, |
| 18 | + type HealthCheckCandidate, | |
| 19 | + type HealthCheckResult, | |
| 17 | 20 | type IntegrationConfig, |
| 18 | 21 | type IntegrationRecord, |
| 19 | 22 | type PluginCtx, |
| @@ -44,6 +47,7 @@ import { · 24 unmodified lines | ||
| 44 | 47 | import { extract } from "./extract"; |
| 45 | 48 | import { |
| 46 | 49 | GraphqlAuthRequiredError, |
| 50 | + GraphqlExtractionError, | |
| 47 | 51 | GraphqlIntrospectionError, |
| 48 | 52 | GraphqlInvocationError, |
| 49 | 53 | } from "./errors"; |
| @@ -76,12 +80,132 @@ const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody); · 26 unmodified lines | ||
| 76 | 80 | const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined => |
| 77 | 81 | Option.getOrUndefined(decodeGraphqlErrorsBody(errors)); |
| 78 | 82 | |
| 83 | +const encodeJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); | |
| 84 | +const parseUrlOption = Option.liftThrowable((value: string) => new URL(value)); | |
| 85 | + | |
| 79 | 86 | const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined => |
| 80 | 87 | errors |
| 81 | 88 | .map((error) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message) |
| 82 | 89 | .find((message) => message !== undefined && message.length > 0); |
| 83 | 90 | |
| 84 | 91 | 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 | +}; | |
| 85 | 209 | |
| 86 | 210 | // --------------------------------------------------------------------------- |
| 87 | 211 | // Extension input shapes |
| @@ -538,7 +662,10 @@ const materializeOperations = ( · 450 unmodified lines | ||
| 538 | 662 | readonly values: Record<string, string | null>; |
| 539 | 663 | }, |
| 540 | 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 | 669 | Effect.gen(function* () { |
| 543 | 670 | // Render the exact method this connection references (we have its slug |
| 544 | 671 | // here, unlike `resolveTools`) so an auth-required endpoint introspects. |
| @@ -565,15 +692,7 @@ const materializeOperations = ( · 20 unmodified lines | ||
| 565 | 692 | Object.keys(queryParams).length > 0 ? queryParams : undefined, |
| 566 | 693 | ).pipe(Effect.provide(httpClientLayer)); |
| 567 | 694 | |
| 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); | |
| 577 | 696 | const prepared = prepareOperations(result.fields, introspection); |
| 578 | 697 | const stored = toStoredOperations(IntegrationSlug.make(integration), prepared); |
| 579 | 698 | yield* ctx.storage.replaceOperations(integration, stored); |
| @@ -621,6 +740,86 @@ export const describeGraphqlIntegrationDisplay = ( · 41 unmodified lines | ||
| 621 | 740 | return { url: config?.endpoint }; |
| 622 | 741 | }; |
| 623 | 742 | |
| 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 | + | |
| 624 | 823 | // --------------------------------------------------------------------------- |
| 625 | 824 | // Plugin extension |
| 626 | 825 | // --------------------------------------------------------------------------- |
| @@ -677,13 +876,16 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 50 unmodified lines | ||
| 677 | 876 | // using that connection's credential. |
| 678 | 877 | if (input.introspectionJson === undefined) { |
| 679 | 878 | 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); | |
| 687 | 889 | }), |
| 688 | 890 | ); |
| 689 | 891 | return { slug: String(slug), name: baseConfig.name, toolCount: 0 }; |
| @@ -701,7 +903,7 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 11 unmodified lines | ||
| 701 | 903 | // written OUTSIDE the transaction — re-puts are idempotent and an |
| 702 | 904 | // aborted register leaves only an unreferenced blob), and the config |
| 703 | 905 | // carries only its hash. |
| 704 | − const snapshotJson = JSON.stringify({ data: introspection }); | |
| 906 | + const snapshotJson = encodeJsonString({ data: introspection }); | |
| 705 | 907 | const introspectionHash = yield* sha256Hex(snapshotJson); |
| 706 | 908 | const config = GraphqlIntegrationConfig.make({ |
| 707 | 909 | ...baseConfig, |
| @@ -729,6 +931,7 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 21 unmodified lines | ||
| 729 | 931 | canRemove: true, |
| 730 | 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 | 1102 | describeAuthMethods: describeGraphqlAuthMethods, |
| 900 | 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 | 1113 | staticSources: (self: GraphqlPluginExtension) => [ |
| 903 | 1114 | { |
| @@ -975,34 +1186,59 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 71 unmodified lines | ||
| 975 | 1186 | readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>; |
| 976 | 1187 | }) => |
| 977 | 1188 | 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 | + ); | |
| 981 | 1198 | const introspectionJson = yield* loadIntrospectionJson(storage, graphqlConfig); |
| 982 | 1199 | // Live introspection (no stored snapshot) needs the connection's |
| 983 | 1200 | // credential inputs for auth-required endpoints; resolve them lazily. |
| 984 | − const values = | |
| 1201 | + const valuesResult = | |
| 985 | 1202 | introspectionJson == null |
| 986 | 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>); | |
| 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( | |
| 991 | 1212 | graphqlConfig, |
| 992 | 1213 | introspectionJson, |
| 993 | − values, | |
| 1214 | + valuesResult.values, | |
| 994 | 1215 | template, |
| 995 | 1216 | 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); | |
| 1001 | 1237 | return { |
| 1002 | 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 | 1244 | // 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 | 1405 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; |
| 1170 | 1406 | const trimmed = url.trim(); |
| 1171 | 1407 | 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); | |
| 1176 | 1409 | if (Option.isNone(parsed)) return null; |
| 1177 | 1410 | |
| 1178 | 1411 | const ok = yield* introspect(trimmed).pipe( |
| @@ -95,6 +95,8 @@ function AccountRow(props: { · 94 unmodified lines | ||
| 95 | 95 | const displayLabel = identity ?? String(connection.name); |
| 96 | 96 | |
| 97 | 97 | const expired = status === "expired"; |
| 98 | + const needsHealthAttention = status === "expired" || status === "degraded"; | |
| 99 | + const healthDetail = needsHealthAttention ? probe?.detail : undefined; | |
| 98 | 100 | |
| 99 | 101 | const handleCheck = async () => { |
| 100 | 102 | if (checking) return; |
| @@ -129,9 +131,9 @@ function AccountRow(props: { · 28 unmodified lines | ||
| 129 | 131 | className={`size-2 shrink-0 rounded-full ${indicator.dot}`} |
| 130 | 132 | /> |
| 131 | 133 | <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]} | |
| 135 | 137 | </Badge> |
| 136 | 138 | ) : null} |
| 137 | 139 | {needsReconsent ? ( |
| @@ -145,6 +147,11 @@ function AccountRow(props: { · 7 unmodified lines | ||
| 145 | 147 | {connection.description} |
| 146 | 148 | </CardStackEntryDescription> |
| 147 | 149 | ) : null} |
| 150 | + {healthDetail ? ( | |
| 151 | + <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground"> | |
| 152 | + {healthDetail} | |
| 153 | + </CardStackEntryDescription> | |
| 154 | + ) : null} | |
| 148 | 155 | {needsReconsent ? ( |
| 149 | 156 | <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground"> |
| 150 | 157 | This connection wasn't granted all the access this integration now needs. |
| @@ -1769,7 +1769,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1768 unmodified lines | ||
| 1769 | 1769 | // Probe the pasted credential WITHOUT saving the connection. With a |
| 1770 | 1770 | // configured check the panel's Check runs this directly; with none it runs |
| 1771 | 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 | 1773 | const payloadOrigin = createCredentialPayloadOrigin({ |
| 1774 | 1774 | origin: credentialOrigin, |
| 1775 | 1775 | inputs: credentialInputs, |
| @@ -1777,7 +1777,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1 unmodified line | ||
| 1777 | 1777 | onePasswordItemId, |
| 1778 | 1778 | singleInput, |
| 1779 | 1779 | }); |
| 1780 | − if (!method || payloadOrigin === null || validating) return; | |
| 1780 | + if (!method || payloadOrigin === null || validating) return null; | |
| 1781 | 1781 | setValidating(true); |
| 1782 | 1782 | const exit = await doValidate({ |
| 1783 | 1783 | payload: { |
| @@ -1794,7 +1794,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 10 unmodified lines | ||
| 1794 | 1794 | if (Exit.isFailure(exit)) { |
| 1795 | 1795 | setValidationResult(null); |
| 1796 | 1796 | toast.error(messageFromExit(exit, "Couldn't check the key")); |
| 1797 | − return; | |
| 1797 | + return null; | |
| 1798 | 1798 | } |
| 1799 | 1799 | const result = exit.value; |
| 1800 | 1800 | setValidationResult(result); |
| @@ -1828,21 +1828,48 @@ function AddAccountModalView(props: AddAccountModalProps) { · 27 unmodified lines | ||
| 1828 | 1828 | return probedIdentity; |
| 1829 | 1829 | }); |
| 1830 | 1830 | } |
| 1831 | + return result; | |
| 1831 | 1832 | }; |
| 1832 | 1833 | |
| 1833 | 1834 | // No configured check: build a draft spec from the picked candidate and |
| 1834 | 1835 | // probe with it. |
| 1835 | − const handleCandidateProbe = async () => { | |
| 1836 | − if (!hcCandidateReady) return; | |
| 1836 | + const handleCandidateProbe = async (): Promise<HealthCheckResult | null> => { | |
| 1837 | + if (!hcCandidateReady) return null; | |
| 1837 | 1838 | const argEntries = Object.entries(hcArgs) |
| 1838 | 1839 | .map(([key, value]) => [key, value.trim()] as const) |
| 1839 | 1840 | .filter(([, value]) => value.length > 0); |
| 1840 | − await handleValidate({ | |
| 1841 | + return await handleValidate({ | |
| 1841 | 1842 | operation: hcOperation, |
| 1842 | 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 | 1873 | // The user clicked a field in the probe's response: that field IS the |
| 1847 | 1874 | // identity. Upgrade the just-saved spec with it, and (unless hand-typed) |
| 1848 | 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 | 2846 | ) : wizardActive && wizardStep === "validate" ? ( |
| 2820 | 2847 | <Button |
| 2821 | 2848 | 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()} | |
| 2832 | 2850 | loading={validating} |
| 2833 | 2851 | > |
| 2834 | 2852 | Continue |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useEffect, useMemo, useRef, useState } from "react"; | |
| 1 | +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 2 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 4 | import { toolSchemaAtom } from "../api/atoms"; |
| @@ -503,18 +503,21 @@ function PolicyBadgeMenu(props: { · 498 unmodified lines | ||
| 503 | 503 | // Empty state |
| 504 | 504 | // --------------------------------------------------------------------------- |
| 505 | 505 | |
| 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); | |
| 507 | 515 | return ( |
| 508 | 516 | <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} | |
| 518 | 521 | </div> |
| 519 | 522 | </div> |
| 520 | 523 | ); |
| @@ -309,6 +309,8 @@ export function ToolTree(props: { · 308 unmodified lines | ||
| 309 | 309 | * beneath each section. The same tool name can appear under two accounts (NOT |
| 310 | 310 | * deduped). When false/unset, render one flat tree (unchanged). */ |
| 311 | 311 | groupByConnection?: boolean; |
| 312 | + /** No-search empty label. Search still uses "No tools match your filter". */ | |
| 313 | + emptyLabel?: string; | |
| 312 | 314 | }) { |
| 313 | 315 | const { |
| 314 | 316 | tools, |
| @@ -319,6 +321,7 @@ export function ToolTree(props: { · 4 unmodified lines | ||
| 319 | 321 | patternForDisplay = toPolicyPattern, |
| 320 | 322 | policies, |
| 321 | 323 | groupByConnection, |
| 324 | + emptyLabel, | |
| 322 | 325 | } = props; |
| 323 | 326 | const exactPatterns = useMemo(() => { |
| 324 | 327 | if (!policies) return new Map<string, ToolPolicyAction>(); |
| @@ -410,7 +413,7 @@ export function ToolTree(props: { · 85 unmodified lines | ||
| 410 | 413 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 411 | 414 | {filteredTools.length === 0 ? ( |
| 412 | 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 | 417 | </div> |
| 415 | 418 | ) : groupByConnection ? ( |
| 416 | 419 | accountGroups.map((group) => ( |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | type Owner, |
| 14 | 14 | } from "@executor-js/sdk/shared"; |
| 15 | 15 | import { |
| 16 | + checkConnectionHealth, | |
| 16 | 17 | connectionsAllAtom, |
| 17 | 18 | integrationToolsAllAtom, |
| 18 | 19 | integrationsOptimisticAtom, |
| @@ -21,7 +22,11 @@ import { · 2 unmodified lines | ||
| 21 | 22 | refreshConnection, |
| 22 | 23 | removeIntegrationOptimistic, |
| 23 | 24 | } from "../api/atoms"; |
| 24 | −import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys"; | |
| 25 | +import { | |
| 26 | + connectionCheckKeys, | |
| 27 | + connectionWriteKeys, | |
| 28 | + integrationWriteKeys, | |
| 29 | +} from "../api/reactivity-keys"; | |
| 25 | 30 | import { ToolTree } from "../components/tool-tree"; |
| 26 | 31 | import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; |
| 27 | 32 | import type { ToolSummary } from "../components/tool-tree"; |
| @@ -36,6 +41,7 @@ import { Skeleton } from "../components/skeleton"; · 8 unmodified lines | ||
| 36 | 41 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 37 | 42 | import { ErrorState } from "../components/error-state"; |
| 38 | 43 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 44 | +import { useConnectionsHealth } from "../lib/use-connection-health"; | |
| 39 | 45 | |
| 40 | 46 | // v2: the route's `namespace` param is the integration slug. Tools belong to |
| 41 | 47 | // 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 | 72 | const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug)); |
| 67 | 73 | const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" }); |
| 68 | 74 | const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" }); |
| 75 | + const doCheckHealth = useAtomSet(checkConnectionHealth, { mode: "promiseExit" }); | |
| 69 | 76 | // Policies are owner-partitioned on write; the integration policy menu writes |
| 70 | 77 | // Workspace (org) rules, preserving the prior default behavior. |
| 71 | 78 | const policyActions = usePolicyActions("org"); |
| @@ -88,6 +95,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 16 unmodified lines | ||
| 88 | 95 | const [confirmDelete, setConfirmDelete] = useState(false); |
| 89 | 96 | const [deleting, setDeleting] = useState(false); |
| 90 | 97 | const [refreshing, setRefreshing] = useState(false); |
| 98 | + const [retryingTools, setRetryingTools] = useState(false); | |
| 91 | 99 | const [editSheetOpen, setEditSheetOpen] = useState(false); |
| 92 | 100 | const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts"); |
| 93 | 101 | const [manualAccountHandoff, setManualAccountHandoff] = |
| @@ -222,6 +230,21 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 128 unmodified lines | ||
| 222 | 230 | ); |
| 223 | 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 | 248 | // Account-grouped tool rows for the Tools tab. NOT deduped across |
| 226 | 249 | // connections: one row per (owner, connection, tool). The leaf's `name` is the |
| 227 | 250 | // 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 | 284 | const selection = selectedToolId ? (selectionById.get(selectedToolId) ?? null) : null; |
| 262 | 285 | const selectedAddress = selection?.address ?? null; |
| 263 | 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 | 300 | // Declared auth methods — derived server-side from the owning plugin's config |
| 266 | 301 | // 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 | 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 | 409 | const handleOpenAddConnection = () => { |
| 346 | 410 | setActiveTab("accounts"); |
| 347 | 411 | setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` }); |
| @@ -478,6 +542,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 130 unmodified lines | ||
| 478 | 542 | onClearPolicy={(pattern) => void policyActions.clear(pattern)} |
| 479 | 543 | policies={sortedPolicies} |
| 480 | 544 | groupByConnection={!isBuiltInIntegration} |
| 545 | + emptyLabel={emptyToolsListLabel} | |
| 481 | 546 | /> |
| 482 | 547 | </div> |
| 483 | 548 | |
| @@ -505,6 +570,23 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 21 unmodified lines | ||
| 505 | 570 | onAddConnection={handleOpenAddConnection} |
| 506 | 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 | 591 | <ToolDetailEmpty hasTools={integrationTools.length > 0} /> |
| 510 | 592 | )} |