Unified · Split
| @@ -1,46 +1,73 @@ | |||
| 1 | // Cross-target (browser): GraphQL source discovery can fail silently at | 1 | // Cross-target (browser): adding a GraphQL source whose endpoint responds with |
| 2 | // connection-create time. The UI journey is the reported shape: add a GraphQL | 2 | // a non-introspection shape must fail at credential validation with actionable |
| 3 | // integration, connect an API key, see the connection saved, then land on an | 3 | // 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": {} }`. | ||
| 9 | import { randomBytes } from "node:crypto"; | 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 | import { expect } from "@effect/vitest"; | 7 | import { expect } from "@effect/vitest"; |
| 13 | import { Effect } from "effect"; | 8 | import { Effect } from "effect"; |
| 14 | import { composePluginApi } from "@executor-js/api/server"; | 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 | import { scenario } from "../src/scenario"; | 13 | import { scenario } from "../src/scenario"; |
| 18 | import { Api, Browser, Target } from "../src/services"; | 14 | import { Api, Browser, Target } from "../src/services"; |
| 19 | 15 | ||
| 20 | const api = composePluginApi([] as const); | 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 | 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 | ||
| 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 | scenario( | 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 | 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 page | 197 | 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 | |||
| 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 | Effect.gen(function* () { | 265 | Effect.gen(function* () { |
| 133 | const connections = yield* apiClient.connections | 266 | 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.connections | 270 | }); |
| 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 | ), |
| @@ -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 | ||
| @@ -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: upstreamMessage | 286 | 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: upstreamMessage | 321 | 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 | ||
| @@ -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 | ||
| 572 | describe("graphqlPlugin", () => { | 766 | describe("graphqlPlugin", () => { |
| @@ -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 | |||
| 44 | import { extract } from "./extract"; | 47 | import { extract } from "./extract"; |
| 45 | import { | 48 | import { |
| 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 | |||
| 76 | const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined => | 80 | const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined => |
| 77 | Option.getOrUndefined(decodeGraphqlErrorsBody(errors)); | 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 | const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined => | 86 | const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined => |
| 80 | errors | 87 | 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 | ||
| 84 | const GRAPHQL_PLUGIN_ID = "graphql"; | 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 | // Extension input shapes | 211 | // 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 slug | 670 | // 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 | ||
| 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 | // Plugin extension | 824 | // 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 an | 903 | // written OUTSIDE the transaction — re-puts are idempotent and an |
| 702 | // aborted register leaves only an unreferenced blob), and the config | 904 | // 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's | 1199 | // 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 == null | 1202 | 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 integration | 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 | 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( |
| @@ -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 | Expired | 136 | {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. |
| @@ -1769,7 +1769,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1768 unmodified lines | |||
| 1769 | // Probe the pasted credential WITHOUT saving the connection. With a | 1769 | // Probe the pasted credential WITHOUT saving the connection. With a |
| 1770 | // configured check the panel's Check runs this directly; with none it runs | 1770 | // 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 and | 1834 | // 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 the | 1873 | // 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 | <Button | 2847 | <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 | Continue | 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 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import { toolSchemaAtom } from "../api/atoms"; | 4 | import { toolSchemaAtom } from "../api/atoms"; |
| @@ -503,18 +503,21 @@ function PolicyBadgeMenu(props: { · 498 unmodified lines | |||
| 503 | // Empty state | 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 | 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 | ); |
| @@ -309,6 +309,8 @@ export function ToolTree(props: { · 308 unmodified lines | |||
| 309 | * beneath each section. The same tool name can appear under two accounts (NOT | 309 | * 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) => ( |
| @@ -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"; |
| 15 | import { | 15 | import { |
| 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"; |
| 24 | import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys"; | 25 | import { |
| 26 | connectionCheckKeys, | ||
| 27 | connectionWriteKeys, | ||
| 28 | integrationWriteKeys, | ||
| 29 | } from "../api/reactivity-keys"; | ||
| 25 | import { ToolTree } from "../components/tool-tree"; | 30 | import { ToolTree } from "../components/tool-tree"; |
| 26 | import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; | 31 | import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; |
| 27 | import type { ToolSummary } from "../components/tool-tree"; | 32 | import type { ToolSummary } from "../components/tool-tree"; |
| @@ -36,6 +41,7 @@ import { Skeleton } from "../components/skeleton"; · 8 unmodified lines | |||
| 36 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 41 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 37 | import { ErrorState } from "../components/error-state"; | 42 | import { ErrorState } from "../components/error-state"; |
| 38 | import { isAsyncResultLoading } from "../lib/async-result"; | 43 | import { isAsyncResultLoading } from "../lib/async-result"; |
| 44 | import { useConnectionsHealth } from "../lib/use-connection-health"; | ||
| 39 | 45 | ||
| 40 | // v2: the route's `namespace` param is the integration slug. Tools belong to | 46 | // 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 is | 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 | 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 writes | 76 | // 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 across | 248 | // Account-grouped tool rows for the Tools tab. NOT deduped across |
| 226 | // connections: one row per (owner, connection, tool). The leaf's `name` is the | 249 | // connections: one row per (owner, connection, tool). The leaf's `name` is the |
| 227 | // policy id `<integration>.<tool>` so leaf policy patterns stay correct, while | 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 | 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 config | 300 | // Declared auth methods — derived server-side from the owning plugin's config |
| 266 | // and carried on the integration catalog response. This is authoritative even | 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 | 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 | )} |