From d1aa3ea75b10e44f7c5f6b742884ba6cace89366 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Tue, 23 Jun 2026 15:14:17 -0700 Subject: [PATCH] feat: connection health checks (liveness) with OpenAPI backing A connection can declare ONE authenticated operation that answers "is this credential still alive?". The probe runs the operation, maps the HTTP status to a health state (2xx healthy, 401/403 expired, other degraded, unset unknown), and reports it. Built for short-lived OAuth tokens (Google's 7-day dev-token revocation): a credential that authenticated yesterday and now 401s reads as expired. Core owns the shared vocabulary (HealthStatus, HealthCheckSpec, HealthCheckResult, HealthCheckCandidate, classifyHttpStatus, candidate ranking); plugins own which operation runs, stored in their opaque integration config and picked the same way auth methods are. Dispatch adds integrations.healthCheck.{get,candidates,set} and connections.{checkHealth,validate}. OpenAPI backing: list ranked candidates (non-destructive GET first), persist the chosen spec (merging over the raw config so provider supersets keep their keys), and run the probe against a resolved credential. React surfaces: - a per-connection status dot + "Check now", - a self-hiding operation editor (a searchable freeform combobox over the whole spec), - "Check the key works" in the Add Connection modal: probe the pasted key before saving; when no check is configured yet, pick a read-only operation inline and a healthy probe saves it as the integration's health check. The operation picker is a base-ui combobox whose popup portals out of the surrounding Radix modal, so this also carries the fixes that make it usable in a modal: pointer-events on the popup, an outside-interaction guard on both the dialog and the sheet (so clicking an option doesn't dismiss the modal), and a non-modal editor sheet (so the popup list can scroll). Covered by e2e: saved connection healthy -> expired; unconfigured -> unknown; the operation picker filters a hundreds-long list on the edit sheet and add screen; Add Connection checks the key against an inline-picked operation (by mouse) and persists it; the editor-sheet combobox is clickable and scrollable. --- e2e/scenarios/health-checks-ui.test.ts | 573 ++++++++++++++++++ e2e/scenarios/health-checks.test.ts | 315 ++++++++++ packages/core/api/src/connections/api.ts | 48 ++ packages/core/api/src/handlers/connections.ts | 34 ++ .../core/api/src/handlers/integrations.ts | 25 + packages/core/api/src/integrations/api.ts | 34 ++ packages/core/sdk/src/connection.ts | 14 + packages/core/sdk/src/executor.ts | 238 ++++++++ packages/core/sdk/src/health-check.ts | 122 ++++ packages/core/sdk/src/index.ts | 15 + packages/core/sdk/src/plugin.ts | 59 ++ packages/core/sdk/src/shared.ts | 12 + .../openapi/src/react/AddOpenApiSource.tsx | 101 ++- .../src/react/OpenApiAccountsPanel.tsx | 2 + packages/plugins/openapi/src/sdk/backing.ts | 222 ++++++- packages/plugins/openapi/src/sdk/config.ts | 5 + packages/plugins/openapi/src/sdk/index.ts | 4 + packages/plugins/openapi/src/sdk/invoke.ts | 2 +- packages/plugins/openapi/src/sdk/plugin.ts | 21 + packages/plugins/openapi/src/sdk/preview.ts | 74 ++- packages/react/src/api/atoms.tsx | 35 ++ packages/react/src/api/reactivity-keys.tsx | 9 + .../react/src/components/accounts-section.tsx | 68 ++- .../src/components/add-account-modal.tsx | 235 ++++++- packages/react/src/components/combobox.tsx | 85 ++- packages/react/src/components/dialog.tsx | 14 + .../src/components/health-check-editor.tsx | 350 +++++++++++ packages/react/src/components/sheet.tsx | 14 + packages/react/src/lib/health-display.ts | 39 ++ .../rules/require-reactivity-keys.js | 4 + 30 files changed, 2733 insertions(+), 40 deletions(-) create mode 100644 e2e/scenarios/health-checks-ui.test.ts create mode 100644 e2e/scenarios/health-checks.test.ts create mode 100644 packages/core/sdk/src/health-check.ts create mode 100644 packages/react/src/components/health-check-editor.tsx create mode 100644 packages/react/src/lib/health-display.ts diff --git a/e2e/scenarios/health-checks-ui.test.ts b/e2e/scenarios/health-checks-ui.test.ts new file mode 100644 index 000000000..c6f25fdf1 --- /dev/null +++ b/e2e/scenarios/health-checks-ui.test.ts @@ -0,0 +1,573 @@ +// Cross-target (browser): the UI side of connection health checks, the feature +// that answers "has this credential expired?" (the Google 7-day dev-token case). +// These scenarios pin the operation picker, the part that lets a user choose +// WHICH call the probe runs: +// +// 1. Add Connection: with no health check configured yet, the user picks a +// read-only operation inline, clicks "Check the key works", the probe runs, +// and the picked operation is saved as the integration's health check. +// 2. Edit sheet, large spec: typing into the operation combobox filters a +// hundreds-long candidate list down to the one match, and committing it +// stores the real operation (not the freeform text typed to find it). +// 3. Add screen, large spec: the same picker is fed by the bounded spec +// preview, so typing must reach an operation ranked well past the preview's +// top slice. +// +// The upstream API is a real node:http server on 127.0.0.1 that gates `GET /me` +// on a bearer token. The probe runs server-side, so the in-process server is +// reachable from the dev server on the same host. +// +// These scenarios skip on targets without a browser surface (selfhost today). +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { Effect } from "effect"; +import { expect } from "@effect/vitest"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import type { Page } from "playwright"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; + +const newSlug = (prefix: string) => + IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`); + +/** OpenAPI 3 spec with an auth-gated GET (`/me`, the obvious health check) plus a + * destructive POST so the candidate ranking has something to sort the GET ahead + * of. */ +const identitySpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Identity API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/me": { + get: { + operationId: "getMe", + summary: "The current account", + responses: { + "200": { + description: "The authenticated account", + content: { + "application/json": { + schema: { + type: "object", + properties: { email: { type: "string" }, login: { type: "string" } }, + }, + }, + }, + }, + }, + }, + }, + "/messages": { + post: { + operationId: "sendMessage", + summary: "Send a message", + responses: { "201": { description: "created" } }, + }, + }, + }, + }); + +/** A real node:http API on 127.0.0.1. `GET /me` returns 200 only when the bearer + * token matches; any other token is a 401. Closed by the scope's finalizer. */ +const serveIdentityApi = (validToken: string) => + Effect.acquireRelease( + Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => { + const server = createServer((request, response) => { + const authorized = request.headers["authorization"] === `Bearer ${validToken}`; + if (request.method === "GET" && (request.url ?? "").startsWith("/me")) { + response.writeHead(authorized ? 200 : 401, { "content-type": "application/json" }); + response.end( + JSON.stringify(authorized ? { email: "alice@example.com" } : { error: "x" }), + ); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +/** Register the identity integration against `baseUrl` with a bearer-token auth + * method (single `token` input → connection `value`). */ +const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, baseUrl: string) => + client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: identitySpec(baseUrl) }, + slug, + baseUrl, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + +/** The stored operation name for the GET probe (openapi prefixes it by tag), + * discovered the same way the editor does: from the ranked candidate list. */ +const getMeOperation = (client: Client, slug: IntegrationSlug) => + Effect.gen(function* () { + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); + const getMe = candidates.find((candidate) => candidate.method === "get"); + if (!getMe) return yield* Effect.die("identity spec exposed no GET candidate"); + return getMe.operation; + }); + +/** Select a combobox option by CLICKING it (the real mouse path). The popup is + * portaled out of the modal, so this exercises both that the option is clickable + * (popup `pointer-events`) and that clicking it does not dismiss the surrounding + * modal (the dialog/sheet outside-interaction guard). */ +const clickComboboxOption = async (page: Page, inputId: string, optionText: string) => { + await page.locator(`#${inputId}`).click(); + const target = page.getByRole("option").filter({ hasText: optionText }).first(); + await target.waitFor({ timeout: 10_000 }); + await target.click(); +}; + +// A distinctive operation buried in a large spec, found by its unique summary +// (which no filler operation shares) so the filter test can search for it. +const PROBE_TOKEN = "ztarget"; +const PROBE_SUMMARY = `Health probe candidate ${PROBE_TOKEN}`; + +/** An OpenAPI 3 spec with ~250 GET operations plus one distinctive probe. The + * candidate list is far longer than the popup renders at once, so the operation + * picker only surfaces a given operation when typing actually filters the list. + * The title is parameterizable so the add screen (which mints the slug from the + * title) gets a collision-free integration per run. */ +const largeSpec = (baseUrl: string, title = "Big API"): string => { + const okJson = { + "200": { + description: "ok", + content: { + "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } }, + }, + }, + }; + const paths: Record = {}; + for (let index = 0; index < 250; index++) { + paths[`/things/item${index}`] = { + get: { operationId: `getThing${index}`, summary: `Thing number ${index}`, responses: okJson }, + }; + } + paths["/probe/target"] = { + get: { operationId: "probeTarget", summary: PROBE_SUMMARY, responses: okJson }, + }; + return JSON.stringify({ + openapi: "3.0.3", + info: { title, version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths, + }); +}; + +// =========================================================================== +// 1. Add Connection, no check configured: pick an operation inline, check the +// key works, and the picked operation is saved as the integration's check. +// =========================================================================== + +scenario( + "Health checks (UI) · Add Connection checks the key against an inline-picked operation and saves it", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const goodToken = `gk_${randomBytes(8).toString("hex")}`; + const server = yield* serveIdentityApi(goodToken); + const slug = newSlug("hc-ui-connect-check"); + + yield* Effect.ensuring( + Effect.gen(function* () { + // Register the integration but deliberately configure NO health check: + // the Add Connection modal must let the user pick one inline. + yield* registerIdentityIntegration(client, slug, server.url); + expect( + yield* client.integrations.healthCheckGet({ params: { slug } }), + "no health check is configured up front", + ).toBeNull(); + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); + const getMe = candidates.find((candidate) => candidate.method === "get"); + if (!getMe) return yield* Effect.die("identity spec exposed no GET candidate"); + + yield* browser.session(identity, async ({ page, step }) => { + const dialog = page.getByRole("dialog"); + + await step("Open the Add Connection modal", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Add connection", exact: true }).click(); + await page.getByRole("heading", { name: /Add connection/ }).waitFor(); + }); + + await step("Paste the key and pick an operation to test it against", async () => { + await dialog.getByPlaceholder("paste the value / token").fill(goodToken); + // No check configured ⇒ the inline operation picker is shown. CLICK + // the option (the real mouse path): the popup is portaled out of the + // dialog, so this proves clicking it neither fails nor dismisses the + // modal before the selection lands. + await clickComboboxOption(page, "connect-health-check-operation", "getMe"); + // The modal must still be open after the click. + await page.getByRole("heading", { name: /Add connection/ }).waitFor(); + }); + + await step("Check the key works: it probes healthy", async () => { + await dialog.getByRole("button", { name: "Check the key works" }).click(); + await dialog.getByText("Healthy").waitFor({ timeout: 30_000 }); + }); + }); + + // The healthy inline check was persisted as the integration's check. + const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); + expect(stored?.operation, "the picked operation was saved as the health check").toBe( + getMe.operation, + ); + }), + client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + }), + ), +); + +// =========================================================================== +// 2. Edit sheet, large spec: typing filters the operation picker to the match. +// =========================================================================== + +scenario( + "Health checks (UI) · large spec: typing filters the operation picker down to the match", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = newSlug("hc-ui-large"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: largeSpec("https://big.example.com") }, + slug, + baseUrl: "https://big.example.com", + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { + authorization: ["Bearer ", { type: "variable", name: "token" }], + }, + }, + ], + }, + }); + // The toolPath the registration assigned the distinctive probe op, + // matched by its unique summary, so the read-back asserts exactly the + // operation the on-camera filter-then-pick selected. + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); + const probe = candidates.find((candidate) => candidate.summary === PROBE_SUMMARY); + if (!probe) return yield* Effect.die("large spec is missing its probe operation"); + const probeOperation = probe.operation; + // Sanity: the spec really is large, so the picker can't just show them all. + expect(candidates.length).toBeGreaterThan(100); + + yield* browser.session(identity, async ({ page, step }) => { + const input = page.locator("#health-check-operation"); + const options = page.getByRole("option"); + + await step("Open the health-check editor over the large spec", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); + await page.getByRole("button", { name: "Set up" }).click(); + await input.waitFor(); + }); + + await step("A broad query still surfaces many operations", async () => { + await input.click(); + // Real keystrokes (base-ui filters on the input value, not a + // programmatic set): a shared summary prefix matches the fillers. + await input.selectText(); + await input.pressSequentially("Thing number", { delay: 10 }); + await options.filter({ hasText: "Thing number" }).first().waitFor(); + // The popup caps how many it renders, but a broad match fills it. + expect(await options.count()).toBeGreaterThan(20); + }); + + await step("A distinctive query narrows the list to the one match", async () => { + await input.selectText(); + await input.pressSequentially(PROBE_TOKEN, { delay: 10 }); + const match = options.filter({ hasText: PROBE_SUMMARY }).first(); + await match.waitFor({ timeout: 10_000 }); + // Typing actually filters: the hundreds collapse to the single + // matching operation (plus the freeform echo of the typed text). + expect(await options.count()).toBeLessThanOrEqual(3); + }); + + await step("Select the filtered operation and save", async () => { + const match = options.filter({ hasText: PROBE_SUMMARY }).first(); + // base-ui pre-highlights the freeform echo; arrow onto the real op. + for (let i = 0; i < 8; i++) { + if ((await match.getAttribute("data-highlighted")) !== null) break; + await input.press("ArrowDown"); + } + await input.press("Enter"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await input.waitFor({ state: "hidden" }); + }); + }); + + // The picker committed the real operation behind the matched summary, + // not the freeform text that was typed to find it. + const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); + expect(stored?.operation).toBe(probeOperation); + }), + Effect.gen(function* () { + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); + +// =========================================================================== +// 3. Add screen, large spec: the operation picker is fed by the bounded spec +// preview, so it must carry enough of a big spec that typing reaches an +// operation ranked well past the preview's top slice (the Vercel "user" +// case: searching found nothing because the op wasn't in the top few). +// =========================================================================== + +scenario( + "Health checks (UI) · add screen large spec: typing reaches an operation beyond the preview's top slice", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + // The add screen mints the slug from the title, so make it unique. Search + // for an operation whose toolPath sorts far past the old top-10 cap. + const title = `Big API ${randomBytes(4).toString("hex")}`; + const spec = largeSpec("https://big.example.com", title); + const targetSummary = "Thing number 137"; + + let createdSlug = ""; + yield* Effect.ensuring( + Effect.gen(function* () { + yield* browser.session(identity, async ({ page, step }) => { + const input = page.locator("#add-health-check-operation"); + const options = page.getByRole("option"); + + await step("Open the Add form and paste the large spec", async () => { + await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); + await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec); + await page + .getByRole("heading", { name: "Health check (optional)" }) + .waitFor({ timeout: 20_000 }); + }); + + await step("Type to reach an operation past the preview's top slice", async () => { + await input.click(); + // Real keystrokes; the operation isn't in the first handful, so it + // is reachable only because the preview now carries the whole spec. + await input.selectText(); + await input.pressSequentially(targetSummary, { delay: 10 }); + // The real option carries the GET label + toolPath; the freeform + // echo is just the typed text, so "GET" disambiguates them. + const match = options.filter({ hasText: targetSummary }).filter({ hasText: "GET" }); + await match.first().waitFor({ timeout: 10_000 }); + }); + + await step("Select the found operation and add the integration", async () => { + const match = options.filter({ hasText: targetSummary }).filter({ hasText: "GET" }); + for (let i = 0; i < 8; i++) { + if ((await match.first().getAttribute("data-highlighted")) !== null) break; + await input.press("ArrowDown"); + } + await input.press("Enter"); + await page.getByRole("button", { name: "Add integration" }).click(); + await page.waitForURL(/\/integrations\/[^/?#]+$/, { timeout: 30_000 }); + const url = page.url().match(/\/integrations\/([^/?#]+)/); + createdSlug = url?.[1] ?? ""; + }); + }); + + expect(createdSlug.length).toBeGreaterThan(0); + const slug = IntegrationSlug.make(createdSlug); + // The drafted check persisted the operation behind the matched summary, + // proving the add-screen search reached past the preview's top slice. + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); + const expected = candidates.find((candidate) => candidate.summary === targetSummary); + if (!expected) return yield* Effect.die("created integration is missing the target op"); + const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); + expect(stored?.operation).toBe(expected.operation); + }), + Effect.gen(function* () { + if (createdSlug.length > 0) { + yield* client.openapi + .removeSpec({ params: { slug: IntegrationSlug.make(createdSlug) } }) + .pipe(Effect.ignore); + } + }), + ); + }), + ), +); + +// =========================================================================== + +scenario( + "Health checks (UI) · clicking a combobox option in the sheet selects it without closing the sheet", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = newSlug("hc-ui-click"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* registerIdentityIntegration(client, slug, "https://identity.example.com"); + const operation = yield* getMeOperation(client, slug); + + yield* browser.session(identity, async ({ page, step }) => { + const sheet = page.getByRole("dialog"); + const operationInput = page.locator("#health-check-operation"); + + await step("Open the health-check editor", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); + await page.getByRole("button", { name: "Set up" }).click(); + await operationInput.waitFor(); + }); + + await step( + "Click the operation option by mouse: it selects and the sheet stays open", + async () => { + await operationInput.click(); + await page.getByRole("option").filter({ hasText: "getMe" }).first().click(); + // Clicking the portaled popup option must NOT dismiss the sheet. + await sheet.waitFor(); + expect(await operationInput.inputValue()).toContain("getMe"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await operationInput.waitFor({ state: "hidden" }); + }, + ); + }); + + // The mouse-driven selection persisted: only possible if the option + // click selected without dismissing the sheet first. + const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); + expect(stored).toEqual({ operation }); + }), + client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + }), + ), +); + +// =========================================================================== +// 7. Edit sheet, scroll: a modal dialog locks body scroll, which freezes the +// portaled combobox list. The editor sheet is non-modal so the list scrolls. +// =========================================================================== + +scenario( + "Health checks (UI) · the combobox list scrolls inside the edit sheet", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = newSlug("hc-ui-scroll"); + + yield* Effect.ensuring( + Effect.gen(function* () { + // Many operations so the popup list overflows and must scroll. + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: largeSpec("https://big.example.com") }, + slug, + baseUrl: "https://big.example.com", + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + const operationInput = page.locator("#health-check-operation"); + const list = page.locator("[data-slot='combobox-list']").first(); + + await step("Open the operation combobox in the sheet", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); + await page.getByRole("button", { name: "Set up" }).click(); + await operationInput.waitFor(); + await operationInput.click(); + await list.waitFor(); + }); + + await step("Wheel-scroll the list: it actually moves (not scroll-locked)", async () => { + const before = await list.evaluate((el) => el.scrollTop); + await list.hover(); + await page.mouse.wheel(0, 600); + // Poll: the wheel scroll must move the list (blocked → stays 0). + await page.waitForFunction( + (start) => { + const el = document.querySelector("[data-slot='combobox-list']"); + return el != null && el.scrollTop > start + 20; + }, + before, + { timeout: 5000 }, + ); + const after = await list.evaluate((el) => el.scrollTop); + expect(after, "the list scrolled past its starting offset").toBeGreaterThan(before); + // The sheet stayed open through the scroll interaction. + await page.getByRole("dialog").waitFor(); + }); + }); + }), + client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + }), + ), +); diff --git a/e2e/scenarios/health-checks.test.ts b/e2e/scenarios/health-checks.test.ts new file mode 100644 index 000000000..b8762fad4 --- /dev/null +++ b/e2e/scenarios/health-checks.test.ts @@ -0,0 +1,315 @@ +// Cross-target: connection health checks, the feature that answers "has this +// credential expired?" (the Google 7-day dev-token case) in one declared probe. +// Entirely through the typed client: +// +// 1. register an OpenAPI integration whose `GET /me` is auth-gated, +// 2. CONFIGURE a health check by picking that operation (the same flow the +// user drives in the editor: list candidates, ranked GET-first, then set), +// 3. CHECK a SAVED connection and watch its status flip healthy -> expired +// when the stored key stops working, +// 4. confirm a connection with no configured check reports `unknown`. +// +// The upstream API is a real node:http server started inside the scenario on +// 127.0.0.1 that gates `GET /me` on a bearer token: a generic "bring your own +// OpenAPI" integration, so the generic openapi health-check path is exercised +// rather than any one provider's quirks. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; + +const TEMPLATE = AuthTemplateSlug.make("apiKey"); +const ACCOUNT_EMAIL = "alice@example.com"; + +const newSlug = (prefix: string) => + IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`); + +/** OpenAPI 3 spec with an auth-gated GET (`/me`, the obvious health check) plus a + * destructive POST so the candidate ranking has something to sort the GET ahead + * of. */ +const identitySpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Identity API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/me": { + get: { + operationId: "getMe", + summary: "The current account", + responses: { + "200": { + description: "The authenticated account", + content: { + "application/json": { + schema: { + type: "object", + properties: { email: { type: "string" }, login: { type: "string" } }, + }, + }, + }, + }, + }, + }, + }, + "/messages": { + post: { + operationId: "sendMessage", + summary: "Send a message", + responses: { "201": { description: "created" } }, + }, + }, + }, + }); + +/** A real node:http identity API on 127.0.0.1. `GET /me` returns the account + * JSON only when the bearer token matches `validToken`; any other token is a + * 401 (the "the dev token got revoked" case the health check classifies as + * expired). Closed by the scope's finalizer. */ +const serveIdentityApi = (validToken: string) => + Effect.acquireRelease( + Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => { + const server = createServer((request, response) => { + const authorized = request.headers["authorization"] === `Bearer ${validToken}`; + if (request.method === "GET" && (request.url ?? "").startsWith("/me")) { + if (!authorized) { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "invalid_token" })); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ email: ACCOUNT_EMAIL, login: "alice" })); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +/** Register the identity integration against `baseUrl` with a bearer-token auth + * method (single `token` input → connection `value`). Returns the slug. */ +const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, baseUrl: string) => + client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: identitySpec(baseUrl) }, + slug, + baseUrl, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + +/** The stored operation name for the GET probe (openapi prefixes it by tag, e.g. + * `me.getMe`), discovered the same way the editor does: from the ranked + * candidate list. */ +const getMeOperation = (client: Client, slug: IntegrationSlug) => + Effect.gen(function* () { + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); + const getMe = candidates.find((candidate) => candidate.method === "get"); + if (!getMe) return yield* Effect.die("identity spec exposed no GET candidate"); + return getMe.operation; + }); + +scenario( + "Health checks · the editor ranks the non-destructive GET ahead of the destructive POST", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const goodToken = `gk_${randomBytes(8).toString("hex")}`; + const server = yield* serveIdentityApi(goodToken); + const slug = newSlug("hc-rank"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* registerIdentityIntegration(client, slug, server.url); + + // The editor offers the integration's operations, ranked so the + // non-destructive GET endpoint floats to the top. + const candidates = yield* client.integrations.healthCheckCandidates({ + params: { slug }, + }); + const get = candidates.find((candidate) => candidate.method === "get"); + const post = candidates.find((candidate) => candidate.method === "post"); + if (!get || !post) { + return yield* Effect.die("identity spec should expose a GET and a POST candidate"); + } + // Operations are stored tag-prefixed (e.g. `me.getMe`); match the suffix. + expect(get.operation.split(".").at(-1), "the GET is offered").toBe("getMe"); + expect(post.operation.split(".").at(-1), "the destructive POST is offered").toBe( + "sendMessage", + ); + expect( + candidates[0]?.operation, + "the non-destructive GET ranks ahead of the destructive POST", + ).toBe(get.operation); + expect(get.destructive, "the GET probe is non-destructive").toBe(false); + expect(post.destructive, "the POST is flagged destructive").toBe(true); + + // Pick it: just the operation (a pure liveness probe). + yield* client.integrations.healthCheckSet({ + params: { slug }, + payload: { spec: { operation: get.operation } }, + }); + const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); + expect(stored, "the chosen health check round-trips").toEqual({ + operation: get.operation, + }); + }), + client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + }), + ), +); + +scenario( + "Health checks · a saved connection reports healthy, then expired when its key stops working", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const goodToken = `gk_${randomBytes(8).toString("hex")}`; + const server = yield* serveIdentityApi(goodToken); + const slug = newSlug("hc-saved"); + const name = ConnectionName.make("main"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* registerIdentityIntegration(client, slug, server.url); + const operation = yield* getMeOperation(client, slug); + yield* client.integrations.healthCheckSet({ + params: { slug }, + payload: { spec: { operation } }, + }); + + // A connection holding the live key checks out healthy. + yield* client.connections.create({ + payload: { + owner: "org", + name, + integration: slug, + template: TEMPLATE, + value: goodToken, + }, + }); + const healthy = yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name }, + }); + expect(healthy.status, "the saved connection's live key is healthy").toBe("healthy"); + expect(healthy.httpStatus, "the saved probe saw the 200").toBe(200); + + // Re-creating the same (owner, integration, name) replaces the stored + // key in place: now the connection holds a key the server rejects. + yield* client.connections.create({ + payload: { + owner: "org", + name, + integration: slug, + template: TEMPLATE, + value: "rotated-away", + }, + }); + const expired = yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name }, + }); + expect(expired.status, "the same connection now reads as expired").toBe("expired"); + expect(expired.httpStatus, "the saved probe saw the 401").toBe(401); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ params: { owner: "org", integration: slug, name } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); + +scenario( + "Health checks · a connection with no configured check reports unknown, not a failure", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const goodToken = `gk_${randomBytes(8).toString("hex")}`; + const server = yield* serveIdentityApi(goodToken); + const slug = newSlug("hc-unknown"); + const name = ConnectionName.make("main"); + + yield* Effect.ensuring( + Effect.gen(function* () { + // No healthCheckSet: the integration declares no probe. + yield* registerIdentityIntegration(client, slug, server.url); + expect( + yield* client.integrations.healthCheckGet({ params: { slug } }), + "an integration with no configured check reports none", + ).toBeNull(); + + yield* client.connections.create({ + payload: { + owner: "org", + name, + integration: slug, + template: TEMPLATE, + value: goodToken, + }, + }); + const result = yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name }, + }); + expect(result.status, "with no check configured the status is unknown").toBe("unknown"); + expect(result.detail ?? "", "the result explains why it is unknown").toContain( + "No health check configured", + ); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ params: { owner: "org", integration: slug, name } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index 0d44985c2..1843b679d 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -16,6 +16,8 @@ import { ConnectionName, ConnectionNotFoundError, CredentialProviderNotRegisteredError, + HealthCheckResult, + HealthCheckSpec, IntegrationNotFoundError, IntegrationSlug, InternalError, @@ -108,6 +110,31 @@ const CreateConnectionPayload = Schema.Struct({ ), ); +// Validate an in-flight credential WITHOUT saving it (the key-first connect +// flow). Same origin shape as create, plus an optional `spec` override so the +// editor can preview a candidate against a live key. No `name` — the point is +// to derive one from the identity the probe returns. +const ValidateConnectionPayload = Schema.Struct({ + owner: Owner, + integration: IntegrationSlug, + template: AuthTemplateSlug, + spec: Schema.optional(HealthCheckSpec), + value: Schema.optional(Schema.String), + values: Schema.optional(Schema.Record(Schema.String, Schema.String)), + from: Schema.optional( + Schema.Struct({ + provider: ProviderKey, + id: ProviderItemId, + }), + ), +}).check( + Schema.makeFilter((payload) => + [payload.value, payload.values, payload.from].filter(Predicate.isNotUndefined).length === 1 + ? undefined + : "Expected exactly one credential origin", + ), +); + // --------------------------------------------------------------------------- // Query — optional list filters. // --------------------------------------------------------------------------- @@ -186,4 +213,25 @@ export const ConnectionsApi = HttpApiGroup.make("connections") success: Schema.Array(ToolResponse), error: [InternalError, ConnectionNotFound, IntegrationNotFound], }), + ) + // Run the integration's declared health check against a SAVED connection: is + // this credential still alive (Google's 7-day dev-token revocation), and whose + // account is it? Returns a classified status + optional identity, never an + // error for an auth wall (that surfaces as `status: "expired"`). + .add( + HttpApiEndpoint.post("checkHealth", "/connections/:owner/:integration/:name/health", { + params: ConnectionParams, + success: HealthCheckResult, + error: [InternalError, ConnectionNotFound, IntegrationNotFound], + }), + ) + // Run the health check against an IN-FLIGHT credential without saving it (the + // key-first connect flow): confirm the pasted key works and surface the + // identity the UI derives a connection name from before anything persists. + .add( + HttpApiEndpoint.post("validate", "/connections/validate", { + payload: ValidateConnectionPayload, + success: HealthCheckResult, + error: [InternalError, IntegrationNotFound], + }), ); diff --git a/packages/core/api/src/handlers/connections.ts b/packages/core/api/src/handlers/connections.ts index d3ffb5745..a4a1ba681 100644 --- a/packages/core/api/src/handlers/connections.ts +++ b/packages/core/api/src/handlers/connections.ts @@ -7,7 +7,9 @@ import { type Connection, type ConnectionRef, type CreateConnectionInput, + type HealthCheckResult, type Tool, + type ValidateConnectionInput, } from "@executor-js/sdk"; import { ExecutorApi } from "../api"; @@ -38,6 +40,13 @@ const toolToResponse = (t: Tool) => ({ description: t.description, }); +const toHealthResponse = (r: HealthCheckResult) => ({ + status: r.status, + checkedAt: r.checkedAt, + ...(r.httpStatus !== undefined ? { httpStatus: r.httpStatus } : {}), + ...(r.detail !== undefined ? { detail: r.detail } : {}), +}); + export const ConnectionsHandlers = HttpApiBuilder.group(ExecutorApi, "connections", (handlers) => handlers .handle("list", ({ query }) => @@ -130,5 +139,30 @@ export const ConnectionsHandlers = HttpApiBuilder.group(ExecutorApi, "connection return tools.map(toolToResponse); }), ), + ) + .handle("checkHealth", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const result = yield* executor.connections.checkHealth({ + owner: path.owner, + integration: path.integration, + name: path.name, + }); + return toHealthResponse(result); + }), + ), + ) + .handle("validate", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + // The payload mirrors `ValidateConnectionInput`: owner/integration/ + // template/spec plus a single credential origin (`value` | `values` | + // `from`). Pass it through verbatim. + const result = yield* executor.connections.validate(payload as ValidateConnectionInput); + return toHealthResponse(result); + }), + ), ), ); diff --git a/packages/core/api/src/handlers/integrations.ts b/packages/core/api/src/handlers/integrations.ts index 25ca35685..91676aaa0 100644 --- a/packages/core/api/src/handlers/integrations.ts +++ b/packages/core/api/src/handlers/integrations.ts @@ -79,5 +79,30 @@ export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrati })); }), ), + ) + .handle("healthCheckGet", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.integrations.healthCheck.get(path.slug); + }), + ), + ) + .handle("healthCheckCandidates", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.integrations.healthCheck.candidates(path.slug); + }), + ), + ) + .handle("healthCheckSet", ({ params: path, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.integrations.healthCheck.set(path.slug, payload.spec); + return { ok: true }; + }), + ), ), ); diff --git a/packages/core/api/src/integrations/api.ts b/packages/core/api/src/integrations/api.ts index c92dc1db3..5f90211f2 100644 --- a/packages/core/api/src/integrations/api.ts +++ b/packages/core/api/src/integrations/api.ts @@ -11,6 +11,8 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; import { + HealthCheckCandidate, + HealthCheckSpec, IntegrationDetectionResult, IntegrationNotFoundError, IntegrationRemovalNotAllowedError, @@ -88,6 +90,12 @@ const DetectRequest = Schema.Struct({ url: Schema.String.check(Schema.isMaxLength(2_048)), }); +// Set (or clear, with null) the integration's declared health check. The spec +// lives inside the owning plugin's opaque config; core only routes it. +const SetHealthCheckPayload = Schema.Struct({ + spec: Schema.NullOr(HealthCheckSpec), +}); + // --------------------------------------------------------------------------- // Error schemas with HTTP status annotations // --------------------------------------------------------------------------- @@ -136,4 +144,30 @@ export const IntegrationsApi = HttpApiGroup.make("integrations") success: Schema.Array(IntegrationDetectionResult), error: InternalError, }), + ) + // The integration's currently declared health check (null when none is set). + .add( + HttpApiEndpoint.get("healthCheckGet", "/integrations/:slug/health-check", { + params: IntegrationParams, + success: Schema.NullOr(HealthCheckSpec), + error: InternalError, + }), + ) + // Operations the user can pick as the health check, ranked non-destructive + + // fewest-required-args first so the obvious identity endpoint floats to the top. + .add( + HttpApiEndpoint.get("healthCheckCandidates", "/integrations/:slug/health-check/candidates", { + params: IntegrationParams, + success: Schema.Array(HealthCheckCandidate), + error: [InternalError, IntegrationNotFound], + }), + ) + // Persist (or clear) the integration's health check. + .add( + HttpApiEndpoint.put("healthCheckSet", "/integrations/:slug/health-check", { + params: IntegrationParams, + payload: SetHealthCheckPayload, + success: Schema.Struct({ ok: Schema.Boolean }), + error: [InternalError, IntegrationNotFound], + }), ); diff --git a/packages/core/sdk/src/connection.ts b/packages/core/sdk/src/connection.ts index 83b3ad247..ac625388e 100644 --- a/packages/core/sdk/src/connection.ts +++ b/packages/core/sdk/src/connection.ts @@ -8,6 +8,7 @@ import type { ProviderItemId, ProviderKey, } from "./ids"; +import type { HealthCheckSpec } from "./health-check"; /* A Connection is THE saved credential — secret, account, and connection are one * concept — bound to exactly ONE integration (born wired; there is no unwired @@ -95,6 +96,19 @@ export type CreateConnectionInput = { readonly description?: string | null; } & ConnectionValueInput; +/** Validate an in-flight credential WITHOUT saving it (the key-first connect + * flow). Resolves the pasted value(s) the same way `create` would, then runs + * the integration's declared health check so the UI can confirm the key works + * and derive a connection name from the returned identity before anything is + * persisted. `spec` overrides the integration's declared check (used by the + * editor to preview a candidate against a live key). */ +export type ValidateConnectionInput = { + readonly owner: Owner; + readonly integration: IntegrationSlug; + readonly template: AuthTemplateSlug; + readonly spec?: HealthCheckSpec; +} & ConnectionValueInput; + /** Edit a connection's user-curated metadata. Only the provided fields change; * credentials and OAuth lifecycle are untouchable here (recreate or refresh * instead). */ diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 38e12a3c6..2865ae7d3 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -25,7 +25,9 @@ import type { CreateConnectionInput, ConnectionValueInput, UpdateConnectionInput, + ValidateConnectionInput, } from "./connection"; +import type { HealthCheckCandidate, HealthCheckResult, HealthCheckSpec } from "./health-check"; import { coreSchema, isToolPolicyAction, @@ -256,6 +258,29 @@ export type Executor = { readonly detect: ( url: string, ) => Effect.Effect; + /** The integration's declared health check: which authenticated operation a + * connection runs to prove its credential is alive and surface whose + * account it is. Configured by the user the same way auth methods are. */ + readonly healthCheck: { + /** The currently declared check, or null when none is configured (or the + * owning plugin has no health-check capability). */ + readonly get: ( + slug: IntegrationSlug, + ) => Effect.Effect; + /** The operations a user can pick from, ranked non-destructive-first then + * fewest required arguments. Empty when the plugin has no candidates. */ + readonly candidates: ( + slug: IntegrationSlug, + ) => Effect.Effect< + readonly HealthCheckCandidate[], + IntegrationNotFoundError | StorageFailure + >; + /** Declare (or clear, with `null`) the health check for the integration. */ + readonly set: ( + slug: IntegrationSlug, + spec: HealthCheckSpec | null, + ) => Effect.Effect; + }; }; readonly connections: { @@ -288,6 +313,23 @@ export type Executor = { readonly Tool[], ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure >; + /** Run the integration's declared health check against a saved connection: + * classify the credential (healthy / expired / degraded / unknown) and + * extract its identity for display. Never throws on an auth wall or upstream + * error: those come back as a `HealthCheckResult` with the matching status. */ + readonly checkHealth: ( + ref: ConnectionRef, + ) => Effect.Effect< + HealthCheckResult, + ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure + >; + /** Validate an in-flight credential WITHOUT saving it (key-first connect): + * resolve the pasted value(s), run the health check, and return the result + * so the caller can confirm the key works and derive a name from the + * identity before creating the connection. */ + readonly validate: ( + input: ValidateConnectionInput, + ) => Effect.Effect; }; /** Shared OAuth service. Hosts use this through the core HTTP OAuth group; @@ -1698,6 +1740,39 @@ export const createExecutor = { + const runtime = runtimes.get(row.plugin_id); + const describe = runtime?.plugin.describeHealthCheck; + if (!describe) return null; + const record = rowToIntegrationRecord(row); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read + try { + return describe(record); + } catch { + return null; + } + }; + + // The health-check hooks are typed `Effect<_, unknown>` at the PluginSpec + // boundary (each plugin owns its own error shape). Fold that channel into a + // StorageError so the public health-check surface stays StorageFailure-typed. + // A genuine storage failure surfaces here; an auth wall or upstream error is + // a SUCCESSFUL `HealthCheckResult` (status expired/degraded), not a failure. + const foldPluginFailure = ( + effect: Effect.Effect, + message: string, + ): Effect.Effect => + effect.pipe( + Effect.catch((cause: unknown) => + isStorageFailure(cause) + ? Effect.fail(cause) + : Effect.fail(new StorageError({ message, cause })), + ), + ); + const integrationsList = (): Effect.Effect => core .findMany("integration", {}) @@ -1845,6 +1920,54 @@ export const createExecutor = => + findIntegrationRow(slug).pipe( + Effect.map((row) => (row ? describeHealthCheckForRow(row) : null)), + ); + + const integrationHealthCheckCandidates = ( + slug: IntegrationSlug, + ): Effect.Effect => + Effect.gen(function* () { + const row = yield* findIntegrationRow(slug); + if (!row) return yield* new IntegrationNotFoundError({ slug }); + const runtime = runtimes.get(row.plugin_id); + const list = runtime?.plugin.listHealthCheckCandidates; + if (!runtime || !list) return []; + const record = rowToIntegrationRecord(row, describeAuthMethodsForRow(row)); + return yield* foldPluginFailure( + list({ ctx: runtime.ctx, integration: record }), + `Listing health-check candidates for "${slug}" failed.`, + ); + }); + + const integrationSetHealthCheck = ( + slug: IntegrationSlug, + spec: HealthCheckSpec | null, + ): Effect.Effect => + Effect.gen(function* () { + const row = yield* findIntegrationRow(slug); + if (!row) return yield* new IntegrationNotFoundError({ slug }); + const runtime = runtimes.get(row.plugin_id); + const set = runtime?.plugin.setHealthCheck; + if (!runtime || !set) { + return yield* new StorageError({ + message: `Plugin "${row.plugin_id}" does not support health checks.`, + cause: undefined, + }); + } + yield* foldPluginFailure( + set({ ctx: runtime.ctx, integration: slug, spec }), + `Setting the health check for "${slug}" failed.`, + ); + }); + // ------------------------------------------------------------------ // Per-connection tool production // ------------------------------------------------------------------ @@ -2412,6 +2535,114 @@ export const createExecutor = ({ status: "unknown", checkedAt: Date.now() }); + + // Resolve an in-flight credential's value map (key-first validation) without + // saving anything. Mirrors `resolveConnectionValues` for the saved-row path: + // pasted `value`/`values` are used directly; `from` origins resolve through + // their provider. Single-secret sugar lands on the `token` variable. + const resolveInFlightValues = ( + input: ConnectionValueInput, + ): Effect.Effect, StorageFailure> => + Effect.gen(function* () { + const out: Record = {}; + for (const { variable, origin } of normalizeConnectionInputs(input)) { + if ("value" in origin) { + out[variable] = origin.value; + continue; + } + const provider = credentialProviders.get(String(origin.from.provider)); + if (!provider) { + return yield* new StorageError({ + message: `Credential provider "${origin.from.provider}" is not registered.`, + cause: undefined, + }); + } + out[variable] = yield* provider.get(origin.from.id); + } + return out; + }); + + const connectionCheckHealth = ( + ref: ConnectionRef, + ): Effect.Effect< + HealthCheckResult, + ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure + > => + Effect.gen(function* () { + const connectionRow = yield* findConnectionRow(ref); + if (!connectionRow) { + return yield* new ConnectionNotFoundError({ + owner: ref.owner, + integration: ref.integration, + name: ref.name, + }); + } + const integrationRow = yield* findIntegrationRow(ref.integration); + if (!integrationRow) { + return yield* new IntegrationNotFoundError({ slug: ref.integration }); + } + const runtime = runtimes.get(integrationRow.plugin_id); + const check = runtime?.plugin.checkHealth; + if (!runtime || !check) return unknownHealth(); + + const values = yield* foldResolutionFailure(resolveConnectionValues(connectionRow)); + const record = rowToIntegrationRecord( + integrationRow, + describeAuthMethodsForRow(integrationRow), + ); + const credential: ToolInvocationCredential = { + owner: connectionRow.owner as Owner, + integration: ref.integration, + connection: ConnectionName.make(connectionRow.name), + template: AuthTemplateSlug.make(connectionRow.template), + value: values[PRIMARY_INPUT_VARIABLE] ?? null, + values, + config: record.config, + }; + return yield* foldPluginFailure( + check({ ctx: runtime.ctx, integration: record, credential }), + `Health check for connection "${ref.name}" failed.`, + ); + }); + + const connectionValidate = ( + input: ValidateConnectionInput, + ): Effect.Effect => + Effect.gen(function* () { + const integrationRow = yield* findIntegrationRow(input.integration); + if (!integrationRow) { + return yield* new IntegrationNotFoundError({ slug: input.integration }); + } + const runtime = runtimes.get(integrationRow.plugin_id); + const check = runtime?.plugin.checkHealth; + if (!runtime || !check) return unknownHealth(); + + const values = yield* resolveInFlightValues(input); + const record = rowToIntegrationRecord( + integrationRow, + describeAuthMethodsForRow(integrationRow), + ); + const credential: ToolInvocationCredential = { + owner: input.owner, + integration: input.integration, + // No connection exists yet (key-first); a synthetic name keeps the + // credential shape whole. The probe authenticates on values+template, + // not on this name (it only appears in upstream-error messages). + connection: ConnectionName.make("(unsaved)"), + template: input.template, + value: values[PRIMARY_INPUT_VARIABLE] ?? null, + values, + config: record.config, + }; + return yield* foldPluginFailure( + check({ ctx: runtime.ctx, integration: record, credential, spec: input.spec }), + `Validating credential for "${input.integration}" failed.`, + ); + }); + // ------------------------------------------------------------------ // Tools (read surface) // ------------------------------------------------------------------ @@ -3272,6 +3503,11 @@ export const createExecutor = { + if (status >= 200 && status < 300) return "healthy"; + if (status === 401 || status === 403) return "expired"; + return "degraded"; +}; + +/** Stable ranking for the candidate list: non-destructive before destructive, + * then fewest required args, then by method (get first), then alphabetical. + * Returns a negative/zero/positive comparator value. */ +export const compareHealthCheckCandidates = ( + a: HealthCheckCandidate, + b: HealthCheckCandidate, +): number => { + if (a.destructive !== b.destructive) return a.destructive ? 1 : -1; + if (a.requiredArgCount !== b.requiredArgCount) return a.requiredArgCount - b.requiredArgCount; + if (a.method !== b.method) { + if (a.method === "get") return -1; + if (b.method === "get") return 1; + return a.method < b.method ? -1 : 1; + } + return a.operation < b.operation ? -1 : a.operation > b.operation ? 1 : 0; +}; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index bbf46064c..1e1f86f2f 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -92,6 +92,7 @@ export type { ConnectionValueInput, CreateConnectionInput, UpdateConnectionInput, + ValidateConnectionInput, } from "./connection"; export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; @@ -101,6 +102,17 @@ export type { CredentialProvider, ProviderEntry } from "./provider"; // Public projections / detection. export { ToolSchemaView, IntegrationDetectionResult } from "./types"; +// Health-check vocabulary (pure Schema + helpers). +export { + HealthStatus, + HealthCheckSpec, + HealthCheckResult, + HealthCheckCandidate, + HealthCheckCandidateParameter, + classifyHttpStatus, + compareHealthCheckCandidates, +} from "./health-check"; + // Core schema. export { bigintColumn, @@ -292,6 +304,9 @@ export { type ResolveToolsInput, type ResolveToolsResult, type ToolInvocationCredential, + type HealthCheckInput, + type HealthCheckCandidatesInput, + type SetHealthCheckInput, type Elicit, definePlugin, tool, diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 157002ceb..5980afe49 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -19,6 +19,7 @@ import type { IntegrationDisplayDescriptor, RegisterIntegrationInput, } from "./integration"; +import type { HealthCheckCandidate, HealthCheckResult, HealthCheckSpec } from "./health-check"; import type { ToolInvocationRow } from "./core-schema"; import type { AuthTemplateSlug, @@ -255,6 +256,42 @@ export interface ToolInvocationCredential { readonly config: IntegrationConfig; } +// --------------------------------------------------------------------------- +// Health-check hook inputs. A health check is a single declared authenticated +// operation a connection runs to prove its credential is still alive and to +// surface whose account it is. The plugin owns which operation (stored in its +// opaque config); core dispatches these hooks. +// --------------------------------------------------------------------------- + +/** Input to `checkHealth` — run the configured (or overridden) probe against a + * resolved credential. The credential may come from a saved connection OR from + * in-flight values (key-first validation, before the connection is saved). */ +export interface HealthCheckInput { + readonly ctx: PluginCtx; + /** The catalog record (with opaque config) whose health is being checked. */ + readonly integration: IntegrationRecord; + /** The resolved credential to authenticate the probe. */ + readonly credential: ToolInvocationCredential; + /** Optional spec override. When omitted, the plugin uses the health check + * stored in its own config; an override lets the editor test a candidate + * before saving it. */ + readonly spec?: HealthCheckSpec; +} + +/** Input to `listHealthCheckCandidates` — the operations a user can pick. */ +export interface HealthCheckCandidatesInput { + readonly ctx: PluginCtx; + readonly integration: IntegrationRecord; +} + +/** Input to `setHealthCheck` — persist or clear an integration's health check. */ +export interface SetHealthCheckInput { + readonly ctx: PluginCtx; + readonly integration: IntegrationSlug; + /** The new health check, or null to clear it. */ + readonly spec: HealthCheckSpec | null; +} + // --------------------------------------------------------------------------- // Static tool / source declarations. Unchanged from v1 except the ctx shape. // --------------------------------------------------------------------------- @@ -501,6 +538,28 @@ export interface PluginSpec< integration: IntegrationRecord, ) => IntegrationDisplayDescriptor; + /** Project this plugin's opaque integration config into the configured health + * check, when one is set. Synchronous and pure (the config is already + * loaded); must tolerate a malformed/foreign config blob by returning null. + * Absent ⇒ core reports "no health check configured". */ + readonly describeHealthCheck?: (integration: IntegrationRecord) => HealthCheckSpec | null; + + /** List the operations a user can pick as this integration's health check, + * ranked best-first (non-destructive, fewest required args). */ + readonly listHealthCheckCandidates?: ( + input: HealthCheckCandidatesInput, + ) => Effect.Effect; + + /** Persist (or clear, with a null spec) the integration's health check in the + * plugin's opaque config. Mirrors `integrationConfigure`'s read-modify-write. */ + readonly setHealthCheck?: (input: SetHealthCheckInput) => Effect.Effect; + + /** Run the configured (or overridden) health check against a resolved + * credential and classify the outcome into a `HealthCheckResult`. */ + readonly checkHealth?: ( + input: HealthCheckInput, + ) => Effect.Effect; + /** URL autodetection hook for onboarding. */ readonly detect?: (input: { readonly ctx: PluginCtx; diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index fe5546a90..2a7abe38a 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -43,6 +43,7 @@ export type { ConnectionValueInput, CreateConnectionInput, UpdateConnectionInput, + ValidateConnectionInput, } from "./connection"; export type { CredentialProvider, ProviderEntry } from "./provider"; export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; @@ -101,6 +102,17 @@ export { ToolSchemaView, IntegrationDetectionResult } from "./types"; export { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "./oauth"; +// Health-check vocabulary (pure Schema + helpers). +export { + HealthStatus, + HealthCheckSpec, + HealthCheckResult, + HealthCheckCandidate, + HealthCheckCandidateParameter, + classifyHttpStatus, + compareHealthCheckCandidates, +} from "./health-check"; + // OAuth wire contracts (data + tagged errors; the flow impl is server-only). export { type OAuthGrant, diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 8d7057ddc..96b92c9b4 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -4,13 +4,19 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; -import { IntegrationSlug } from "@executor-js/sdk/shared"; -import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import { + IntegrationSlug, + type HealthCheckCandidate, + type HealthCheckSpec, +} from "@executor-js/sdk/shared"; +import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms"; import { slugifyNamespace, useIntegrationIdentity, } from "@executor-js/react/plugins/integration-identity"; import { Button } from "@executor-js/react/components/button"; +import { HealthCheckConfigFields } from "@executor-js/react/components/health-check-editor"; import { AuthMethodListEditor, useAuthMethodList, @@ -99,8 +105,15 @@ export default function AddOpenApiSource(props: { const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); + // Optional health check drafted while adding. Empty `hcOperation` means "not + // drafted": we then leave the integration's auto-detected default untouched + // rather than persisting a blank. + const [hcOperation, setHcOperation] = useState(""); + const [hcArgs, setHcArgs] = useState>({}); + const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); + const doSetHealthCheck = useAtomSet(setIntegrationHealthCheck, { mode: "promiseExit" }); // Keep the latest handleAnalyze in a ref so the debounced effect doesn't need // it as a dependency (it closes over fresh state). @@ -196,15 +209,47 @@ export default function AddOpenApiSource(props: { return templates; }, [authMethodList.rows]); + // Health-check candidates from the bounded preview (top-ranked operations with + // their response fields). The picker is freeform, so a custom op is still + // reachable when the spec exposes none. + const healthCheckCandidates = useMemo( + () => preview?.healthCheckCandidates ?? [], + [preview?.healthCheckCandidates], + ); + const hcSelected = useMemo( + () => healthCheckCandidates.find((c) => c.operation === hcOperation) ?? null, + [healthCheckCandidates, hcOperation], + ); + const hcRequiredParams = useMemo( + () => (hcSelected?.parameters ?? []).filter((p) => p.required), + [hcSelected], + ); + const hcMissingRequired = hcRequiredParams.some( + (p) => (hcArgs[p.name] ?? "").trim().length === 0, + ); + + const onHcOperationChange = (next: string) => { + setHcOperation(next); + // Args are operation-specific; drop them so none dangle onto a freshly + // picked operation. + setHcArgs({}); + }; + const onHcArgChange = (name: string, value: string) => + setHcArgs((prev) => ({ ...prev, [name]: value })); + // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration // whose slug already exists clobbers the existing one's connections/policies, // so the API blocks it. Surface that here from the tenant-scoped catalog list. const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId); // The base URL is optional when the spec declares servers (resolved per call); - // required only when it doesn't. + // required only when it doesn't. A drafted health check must have its required + // args filled (an empty draft, `hcOperation === ""`, imposes no constraint). const canAdd = - preview !== null && !slugAlreadyExists && (!previewHasNoServers || resolvedBaseUrl.length > 0); + preview !== null && + !slugAlreadyExists && + (!previewHasNoServers || resolvedBaseUrl.length > 0) && + !(hcOperation.length > 0 && hcMissingRequired); // ---- Handlers ---- @@ -273,6 +318,32 @@ export default function AddOpenApiSource(props: { return; } + // Persist the drafted health check only when the user actively picked an + // operation; otherwise leave the integration's auto-detected default in + // place. A failure here is non-fatal: the integration is already created and + // the check stays editable from its detail page, so surface it and move on. + if (hcOperation.length > 0) { + const argEntries = Object.entries(hcArgs) + .map(([key, value]) => [key, value.trim()] as const) + .filter(([, value]) => value.length > 0); + const spec: HealthCheckSpec = { + operation: hcOperation, + ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), + }; + const exit = await doSetHealthCheck({ + params: { slug: integration }, + payload: { spec }, + reactivityKeys: healthCheckWriteKeys, + }); + if (Exit.isFailure(exit)) { + setAddError( + errorMessageFromExit(exit, "Integration added, but saving the health check failed"), + ); + setAdding(false); + return; + } + } + props.onComplete(String(integration)); }; @@ -359,6 +430,28 @@ export default function AddOpenApiSource(props: { /> )} + {preview && healthCheckCandidates.length > 0 && ( +
+
+

Health check (optional)

+

+ One read-only call Executor runs to tell whether a connection's credential is still + alive. You can change this later from the integration page. +

+
+ +
+ )} + {preview && slugAlreadyExists && !adding && } {addError && } diff --git a/packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx b/packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx index f8453b7bf..fb747f78e 100644 --- a/packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx +++ b/packages/plugins/openapi/src/react/OpenApiAccountsPanel.tsx @@ -6,6 +6,7 @@ import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared"; import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; import { AccountsSection } from "@executor-js/react/components/accounts-section"; +import { HealthCheckEditor } from "@executor-js/react/components/health-check-editor"; import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; import { @@ -113,6 +114,7 @@ export default function OpenApiAccountsPanel(props: { createCustomMethod={createCustomMethod} removeCustomMethod={removeCustomMethod} /> + ); } diff --git a/packages/plugins/openapi/src/sdk/backing.ts b/packages/plugins/openapi/src/sdk/backing.ts index 2415eeb6c..1987b8f16 100644 --- a/packages/plugins/openapi/src/sdk/backing.ts +++ b/packages/plugins/openapi/src/sdk/backing.ts @@ -7,6 +7,14 @@ import { ToolName, ToolResult, authToolFailure, + classifyHttpStatus, + compareHealthCheckCandidates, + type HealthCheckCandidate, + type HealthCheckResult, + type HealthCheckSpec, + type IntegrationConfig, + type IntegrationRecord, + type IntegrationSlug, type PluginCtx, type ResolveToolsResult, type StorageFailure, @@ -28,7 +36,7 @@ import { streamOperationBindingsFromStructure, } from "./extract"; import { compileToolDefinitions, type ToolDefinition } from "./definitions"; -import { annotationsForOperation, invokeWithLayer } from "./invoke"; +import { annotationsForOperation, invokeWithLayer, REQUIRE_APPROVAL } from "./invoke"; import { parse, type ParsedDocument } from "./parse"; import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split"; import { type OpenapiStore, type StoredOperation } from "./store"; @@ -663,3 +671,215 @@ export const resolveOpenApiBackedAnnotations = (input: { } return out; }); + +// --------------------------------------------------------------------------- +// Health checks — the declared liveness probe for a connection. +// --------------------------------------------------------------------------- + +/** Resolve the invocation binding for a health-check operation. Unlike the tool + * invoke path we do not need `responseBody` (the probe reads the raw response), + * so the stored binding is enough; only recompile the spec when it is missing + * (e.g. an operation added to the spec but not yet persisted). */ +const resolveHealthCheckBinding = ( + ctx: PluginCtx, + integration: string, + operation: string, + config: OpenApiIntegrationConfig | null, +): Effect.Effect => + Effect.gen(function* () { + const stored = (yield* ctx.storage.getOperation(integration, operation))?.binding; + if (stored) return stored; + if (!config) return undefined; + const specText = yield* loadOpenApiSpecText(ctx.storage, config).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (specText == null) return undefined; + const compiled = yield* compileOpenApiSpec(specText).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (!compiled) return undefined; + return openApiStoredOperationsFromCompiled(integration, compiled).find( + (op) => op.toolName === operation, + )?.binding; + }); + +/** Run the configured (or overridden) probe against a resolved credential and + * classify the outcome. Never fails for credential/upstream reasons: a rejected + * credential is a `HealthCheckResult` with `status: "expired"`, not an error. */ +export const checkHealthOpenApi = (input: { + readonly ctx: PluginCtx; + readonly integration: IntegrationRecord; + readonly credential: ToolInvocationCredential; + readonly spec?: HealthCheckSpec; + readonly httpClientLayer: Layer.Layer; +}): Effect.Effect => + Effect.gen(function* () { + const checkedAt = Date.now(); + const config = decodeOpenApiIntegrationConfig(input.integration.config); + const spec = input.spec ?? config?.healthCheck; + if (!spec) { + return { + status: "unknown", + checkedAt, + detail: "No health check configured.", + } satisfies HealthCheckResult; + } + + const integration = String(input.integration.slug); + const binding = yield* resolveHealthCheckBinding( + input.ctx, + integration, + spec.operation, + config, + ); + if (!binding) { + return { + status: "unknown", + checkedAt, + detail: `Health check operation "${spec.operation}" not found on "${integration}".`, + } satisfies HealthCheckResult; + } + + const headers: Record = { ...(config?.headers ?? {}) }; + const queryParams: Record = { ...(config?.queryParams ?? {}) }; + + const template = (config?.authenticationTemplate ?? []).find( + (entry) => String(entry.slug) === String(input.credential.template), + ); + if (template) { + const missing = requiredTemplateVariables(template).filter((name) => { + const value = input.credential.values[name]; + return value == null || value === ""; + }); + if (missing.length > 0) { + return { + status: "expired", + checkedAt, + detail: `Connection "${input.credential.connection}" has no resolvable credential value.`, + } satisfies HealthCheckResult; + } + const rendered = renderAuthTemplate(template, input.credential.values); + Object.assign(headers, rendered.headers); + Object.assign(queryParams, rendered.queryParams); + } + + // `invokeWithLayer` fails only with the typed `OpenApiInvocationError` + // (transport / body-read failures before an HTTP status); fold it onto the + // success channel so a dead upstream reads as `degraded`, not a thrown error. + const probe = yield* invokeWithLayer( + binding, + { ...(spec.args ?? {}) } as Record, + config?.baseUrl ?? "", + headers, + queryParams, + input.httpClientLayer, + ).pipe( + Effect.map((result) => ({ ok: true as const, result })), + Effect.catch((failure) => Effect.succeed({ ok: false as const, failure })), + ); + + if (!probe.ok) { + return { + status: "degraded", + checkedAt, + detail: `Health check request failed: ${probe.failure.message}`, + } satisfies HealthCheckResult; + } + + const status = classifyHttpStatus(probe.result.status); + return { + status, + httpStatus: probe.result.status, + checkedAt, + ...(status === "healthy" + ? {} + : { detail: extractOpenApiUpstreamMessage(probe.result.error, probe.result.status) }), + } satisfies HealthCheckResult; + }); + +/** List the operations a user can pick as the health check, ranked + * non-destructive-first then fewest-required-args so the obvious "GET /me" + * style endpoint floats to the top. Recompiles the spec once (best-effort) + * to recover human summaries the stored binding does not keep. */ +export const listHealthCheckCandidatesOpenApi = (input: { + readonly ctx: PluginCtx; + readonly integration: IntegrationRecord; +}): Effect.Effect => + Effect.gen(function* () { + const integration = String(input.integration.slug); + const operations = yield* input.ctx.storage.listOperations(integration); + const config = decodeOpenApiIntegrationConfig(input.integration.config); + + const summaries = new Map(); + if (config) { + const specText = yield* loadOpenApiSpecText(input.ctx.storage, config).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + const compiled = + specText == null + ? null + : yield* compileOpenApiSpec(specText).pipe(Effect.catch(() => Effect.succeed(null))); + if (compiled) { + for (const def of compiled.definitions) { + const summary = + Option.getOrUndefined(def.operation.summary) ?? + Option.getOrUndefined(def.operation.description); + if (summary) summaries.set(def.toolPath, summary); + } + } + } + + const candidates = operations.map((op): HealthCheckCandidate => { + const method = op.binding.method.toLowerCase(); + const parameters = op.binding.parameters.map((parameter) => ({ + name: parameter.name, + location: parameter.location, + required: parameter.required, + ...(Option.isSome(parameter.description) + ? { description: parameter.description.value } + : {}), + })); + return { + operation: op.toolName, + method, + requiredArgCount: op.binding.parameters.filter((parameter) => parameter.required).length, + destructive: REQUIRE_APPROVAL.has(method), + summary: summaries.get(op.toolName) ?? `${method.toUpperCase()} ${op.binding.pathTemplate}`, + ...(parameters.length > 0 ? { parameters } : {}), + }; + }); + return [...candidates].sort(compareHealthCheckCandidates); + }); + +/** Persist (or clear, when `spec` is null) the integration's health check. + * Read-modify-write of the opaque config inside a transaction, mirroring + * `configure`. */ +export const setHealthCheckOpenApi = (input: { + readonly ctx: PluginCtx; + readonly integration: IntegrationSlug; + readonly spec: HealthCheckSpec | null; +}): Effect.Effect => + input.ctx.transaction( + Effect.gen(function* () { + const record = yield* input.ctx.core.integrations.get(input.integration); + if (!record) return; + // Guard: only touch configs that decode as the openapi shape. Merge over + // the RAW config object (not the decoded one, which strips unknown keys) + // so provider supersets (Microsoft presets, Google discovery URLs) survive + // a health-check write. `healthCheck: undefined` drops the key on JSON + // serialization, clearing the stored health check. + if (decodeOpenApiIntegrationConfig(record.config) === null) return; + const raw = (record.config ?? {}) as Record; + const next = input.spec + ? { ...raw, healthCheck: input.spec } + : { ...raw, healthCheck: undefined }; + yield* input.ctx.core.integrations.update(input.integration, { + config: next as IntegrationConfig, + }); + }), + ); + +/** Pure projector: the health check declared in the integration's config, or + * null when none is configured. */ +export const describeHealthCheckOpenApi = (record: IntegrationRecord): HealthCheckSpec | null => + decodeOpenApiIntegrationConfig(record.config)?.healthCheck ?? null; diff --git a/packages/plugins/openapi/src/sdk/config.ts b/packages/plugins/openapi/src/sdk/config.ts index 8001c2fa9..41aa4df6e 100644 --- a/packages/plugins/openapi/src/sdk/config.ts +++ b/packages/plugins/openapi/src/sdk/config.ts @@ -5,6 +5,7 @@ import { renderAuthPlacements, requiredPlacementVariables, } from "@executor-js/sdk/http-auth"; +import { HealthCheckSpec } from "@executor-js/sdk/core"; import type { Authentication } from "./types"; @@ -51,6 +52,10 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), /** The auth methods a connection's value can be applied through. */ authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), + /** The declared health check: which operation a connection runs to prove its + * credential is alive and surface whose account it is. Picked by the user + * the same way auth methods are configured. Absent = no health check. */ + healthCheck: Schema.optional(HealthCheckSpec), }); export type OpenApiIntegrationConfig = Omit< diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts index ea9ef5041..e70b23372 100644 --- a/packages/plugins/openapi/src/sdk/index.ts +++ b/packages/plugins/openapi/src/sdk/index.ts @@ -15,19 +15,23 @@ export { export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke"; export { buildDefsJsonStreaming, + checkHealthOpenApi, compileAndPersistOpenApiOperations, compileAndPersistOpenApiSpec, compileAndPersistOpenApiSpecStreaming, compileOpenApiDocument, compileOpenApiSpec, + describeHealthCheckOpenApi, extractOpenApiUpstreamMessage, invokeOpenApiBackedTool, + listHealthCheckCandidatesOpenApi, loadOpenApiSpecText, normalizeOpenApiRefs, openApiStoredOperationsFromCompiled, openApiToolDefsFromCompiled, resolveOpenApiBackedAnnotations, resolveOpenApiBackedTools, + setHealthCheckOpenApi, type CompiledOpenApiSpec, type OpenApiPersistResult, } from "./backing"; diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index f17bfc558..2969d6927 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -815,7 +815,7 @@ export const invokeWithLayer = ( // Derive annotations from HTTP method // --------------------------------------------------------------------------- -const REQUIRE_APPROVAL = new Set(["post", "put", "patch", "delete"]); +export const REQUIRE_APPROVAL = new Set(["post", "put", "patch", "delete"]); export const annotationsForOperation = ( method: string, diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index bdfc67ba2..9c3649482 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -32,11 +32,15 @@ import type { Authentication } from "./types"; import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types"; import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; import { + checkHealthOpenApi, compileOpenApiSpec, + describeHealthCheckOpenApi, invokeOpenApiBackedTool, + listHealthCheckCandidatesOpenApi, openApiStoredOperationsFromCompiled, resolveOpenApiBackedAnnotations, resolveOpenApiBackedTools, + setHealthCheckOpenApi, } from "./backing"; // --------------------------------------------------------------------------- @@ -784,6 +788,23 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { describeAuthMethods: describeOpenApiAuthMethods, describeIntegrationDisplay: describeOpenApiIntegrationDisplay, + // Health checks: the declared liveness/identity probe. `describeHealthCheck` + // is a pure projector off the opaque config; the rest run operations or + // read-modify-write the config, so they thread the plugin's http layer / ctx. + describeHealthCheck: describeHealthCheckOpenApi, + listHealthCheckCandidates: (input) => + listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }), + setHealthCheck: (input) => + setHealthCheckOpenApi({ ctx: input.ctx, integration: input.integration, spec: input.spec }), + checkHealth: (input) => + checkHealthOpenApi({ + ctx: input.ctx, + integration: input.integration, + credential: input.credential, + spec: input.spec, + httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer, + }), + // Produce one tool per spec operation. Spec-derived, identical for every // connection on the integration - so `getValue` is never called here. The // operation bindings invokeTool needs are persisted at addSpec time; this diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts index 63facf7ef..5c3999ba7 100644 --- a/packages/plugins/openapi/src/sdk/preview.ts +++ b/packages/plugins/openapi/src/sdk/preview.ts @@ -1,10 +1,25 @@ import { Effect, Option, Predicate } from "effect"; import { Schema } from "effect"; +import { HealthCheckCandidate, compareHealthCheckCandidates } from "@executor-js/sdk/core"; + import { parse, resolveSpecText, type ParsedDocument } from "./parse"; import { extract } from "./extract"; +import { compileToolDefinitions } from "./definitions"; import { DocResolver } from "./openapi-utils"; -import { HttpMethod, ServerInfo, type ExtractionResult } from "./types"; +import { HttpMethod, ServerInfo, type ExtractedOperation, type ExtractionResult } from "./types"; + +// Mutating HTTP methods — mirrors `REQUIRE_APPROVAL` in `./invoke` but kept +// inline so this browser-safe preview module never pulls in the HTTP execution +// path. A health check should be safe to re-run, so these rank last. +const DESTRUCTIVE_METHODS = new Set(["post", "put", "patch", "delete"]); + +// Cap on health-check candidate METADATA carried in the preview, so the add +// screen's operation picker can search the whole spec (not just a top few). +// Building this is cheap (the rank/sort already runs over every operation); only +// the payload grows, so the cap bounds Graph-sized specs (16k+ ops) to the +// top-ranked 1000. Beyond that, the picker stays freeform (type an exact op). +const MAX_PREVIEW_CANDIDATES = 1000; // --------------------------------------------------------------------------- // OAuth 2.0 flows — one entry per supported grant type @@ -151,6 +166,9 @@ export const SpecPreview = Schema.Struct({ headerPresets: Schema.Array(HeaderPreset), /** OAuth2 presets — one per (oauth2 scheme × supported flow) combination */ oauth2Presets: Schema.Array(OAuth2Preset), + /** Top-ranked health-check candidates (bounded), so the add screen can offer a + * typed operation picker before the integration is registered. */ + healthCheckCandidates: Schema.Array(HealthCheckCandidate), }); export type SpecPreview = typeof SpecPreview.Type; @@ -168,6 +186,7 @@ export const SpecPreviewSummary = Schema.Struct({ authStrategies: Schema.Array(AuthStrategy), headerPresets: Schema.Array(HeaderPreset), oauth2Presets: Schema.Array(OAuth2Preset), + healthCheckCandidates: Schema.Array(HealthCheckCandidate), }); export type SpecPreviewSummary = typeof SpecPreviewSummary.Type; @@ -183,6 +202,7 @@ export const specPreviewSummary = (preview: SpecPreview): SpecPreviewSummary => authStrategies: preview.authStrategies, headerPresets: preview.headerPresets, oauth2Presets: preview.oauth2Presets, + healthCheckCandidates: preview.healthCheckCandidates, }); // --------------------------------------------------------------------------- @@ -403,6 +423,57 @@ const collectTags = (result: ExtractionResult): string[] => { return [...tagSet].sort(); }; +// --------------------------------------------------------------------------- +// Health-check candidates (bounded) for the add screen +// --------------------------------------------------------------------------- + +/** + * Project the top-ranked health-check candidates from a parsed doc + its + * extracted operations, so the add screen can offer a typed operation picker + * before registration. + * + * Tool paths are computed on the FULL operation set (`compileToolDefinitions` + * collision resolution is stateful, so they must match the paths the operations + * get at registration). Candidates are ranked and sliced to + * `MAX_PREVIEW_CANDIDATES` (enough for the operation picker to search the whole + * spec). + */ +const buildPreviewHealthCheckCandidates = ( + _doc: ParsedDocument, + operations: readonly ExtractedOperation[], +): HealthCheckCandidate[] => { + if (operations.length === 0) return []; + + const definitions = compileToolDefinitions(operations); + + return definitions + .map((def): HealthCheckCandidate => { + const op = def.operation; + const method = op.method.toLowerCase(); + const parameters = op.parameters.map((parameter) => ({ + name: parameter.name, + location: parameter.location, + required: parameter.required, + ...(Option.isSome(parameter.description) + ? { description: parameter.description.value } + : {}), + })); + return { + operation: def.toolPath, + method, + requiredArgCount: op.parameters.filter((parameter) => parameter.required).length, + destructive: DESTRUCTIVE_METHODS.has(method), + summary: + Option.getOrUndefined(op.summary) ?? + Option.getOrUndefined(op.description) ?? + `${method.toUpperCase()} ${op.pathTemplate}`, + ...(parameters.length > 0 ? { parameters } : {}), + }; + }) + .sort(compareHealthCheckCandidates) + .slice(0, MAX_PREVIEW_CANDIDATES); +}; + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -448,6 +519,7 @@ export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (s authStrategies, headerPresets: buildHeaderPresets(securitySchemes, authStrategies), oauth2Presets: buildOAuth2Presets(securitySchemes), + healthCheckCandidates: buildPreviewHealthCheckCandidates(doc, result.operations), }); }); diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 9cc364fda..b274b282b 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -76,6 +76,25 @@ export const integrationAtom = (slug: IntegrationSlug) => (integrations) => integrations.find((i) => i.slug === slug) ?? null, ); +/** The integration's currently declared health check (null when none is set). + * Backs the editor's "current selection" and the status surfaces deciding + * whether a connection can be probed at all. */ +export const integrationHealthCheckAtom = (slug: IntegrationSlug) => + ExecutorApiClient.query("integrations", "healthCheckGet", { + params: { slug }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.healthChecks], + }); + +/** Operations the user can pick as the health check, server-ranked + * (non-destructive + fewest-required-args first). Backs the editor picker. */ +export const integrationHealthCheckCandidatesAtom = (slug: IntegrationSlug) => + ExecutorApiClient.query("integrations", "healthCheckCandidates", { + params: { slug }, + timeToLive: "5 minutes", + reactivityKeys: [ReactivityKey.integrations], + }); + // --------------------------------------------------------------------------- // Connections — owner-scoped credentials (was `secrets` + `connections`). // --------------------------------------------------------------------------- @@ -147,12 +166,28 @@ export const updateConnection = ExecutorApiClient.mutation("connections", "updat export const refreshConnection = ExecutorApiClient.mutation("connections", "refresh"); +/** Probe a SAVED connection's health on demand (the "Check now" button). A + * read-only probe: returns a classified `HealthCheckResult`, persists nothing, + * so no reactivity keys. */ +export const checkConnectionHealth = ExecutorApiClient.mutation("connections", "checkHealth"); + +/** Validate an IN-FLIGHT credential without saving it (the key-first connect + * flow). Returns the probe result the UI derives a connection name from. */ +export const validateConnection = ExecutorApiClient.mutation("connections", "validate"); + export const updateIntegration = ExecutorApiClient.mutation("integrations", "update"); export const removeIntegration = ExecutorApiClient.mutation("integrations", "remove"); export const detectIntegration = ExecutorApiClient.mutation("integrations", "detect"); +/** Set or clear an integration's declared health check. + * Pass `reactivityKeys: healthCheckWriteKeys` at the call site. */ +export const setIntegrationHealthCheck = ExecutorApiClient.mutation( + "integrations", + "healthCheckSet", +); + // --------------------------------------------------------------------------- // OAuth — v2 flow. `start` runs a registered client to mint a connection for an // integration; `complete` exchanges the authorization code; `cancel` drops an diff --git a/packages/react/src/api/reactivity-keys.tsx b/packages/react/src/api/reactivity-keys.tsx index e8fe0ed70..7306c44af 100644 --- a/packages/react/src/api/reactivity-keys.tsx +++ b/packages/react/src/api/reactivity-keys.tsx @@ -28,6 +28,8 @@ export const ReactivityKey = { policies: "policies", /** Registered OAuth clients (apps). */ oauthClients: "oauth-clients", + /** An integration's declared health check (the operation/identity-field spec). */ + healthChecks: "health-checks", // cloud-only resources orgMembers: "org:members", orgDomains: "org:domains", @@ -47,6 +49,13 @@ export const connectionWriteKeys = [ReactivityKey.connections, ReactivityKey.too /** Mutations that register / replace an OAuth client (app). */ export const oauthClientWriteKeys = [ReactivityKey.oauthClients] as const; +/** Mutations that set/clear an integration's health check. Touches `integrations` + * so any integration view re-reads (the spec is derived from integration config). */ +export const healthCheckWriteKeys = [ + ReactivityKey.healthChecks, + ReactivityKey.integrations, +] as const; + /** Mutations that mutate tool policies. Also touches `tools` because * `tools.list` filters blocked tools — adding/removing a `block` * policy changes what the tools page shows. */ diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index 7871d485a..610f2d036 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -2,18 +2,26 @@ import { useEffect, useMemo, useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; -import { IntegrationSlug, type Connection, type Owner } from "@executor-js/sdk/shared"; +import { + IntegrationSlug, + type Connection, + type HealthCheckResult, + type HealthStatus, + type Owner, +} from "@executor-js/sdk/shared"; import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; import { toast } from "sonner"; import { addConnectionOptimistic, + checkConnectionHealth, connectionsForIntegrationAtom, refreshConnection, removeConnectionOptimistic, startOAuth, } from "../api/atoms"; import { connectionWriteKeys } from "../api/reactivity-keys"; +import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display"; import { messageFromExit } from "../api/error-reporting"; import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; import { trackEvent } from "../api/analytics"; @@ -68,16 +76,67 @@ function AccountRow(props: { readonly onRemove: () => void; }) { const { connection, needsReconsent } = props; + // A live probe result, once "Check now" has run, drives the status indicator. + const [probe, setProbe] = useState(null); + const [checking, setChecking] = useState(false); + const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" }); + + // Status comes only from a live probe ("Check now"). We deliberately do NOT + // derive expiry from the stored `expiresAt`: that's the access-token lifetime, + // which refreshes, so a passive countdown / "expired" reads as alarming but + // means nothing. Until a probe runs the connection is "Unchecked". + const status: HealthStatus = probe?.status ?? "unknown"; + const indicator = HEALTH_INDICATOR_COLOR[status]; + const displayLabel = connection.identityLabel && connection.identityLabel.length > 0 ? connection.identityLabel : String(connection.name); + const expired = status === "expired"; + + const handleCheck = async () => { + if (checking) return; + setChecking(true); + const exit = await doCheck({ + params: { + owner: connection.owner, + integration: connection.integration, + name: connection.name, + }, + }); + setChecking(false); + if (Exit.isFailure(exit)) { + toast.error(messageFromExit(exit, "Health check failed")); + return; + } + setProbe(exit.value); + if (exit.value.status === "healthy") { + toast.success("Connection is healthy"); + } else if (exit.value.status === "expired") { + toast.error("Connection expired — reconnect to restore access"); + } else if (exit.value.status === "degraded") { + toast.warning(exit.value.detail ?? "Connection check returned an error"); + } else { + toast.message("No health check is configured for this integration"); + } + }; + return ( + {displayLabel} + {expired ? ( + + Expired + + ) : null} {needsReconsent ? ( + void handleCheck()} + > + {checking ? "Checking…" : "Check now"} + Edit diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 918cb06fb..a5456c366 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -8,6 +8,8 @@ import { OAuthClientSlug, ProviderItemId, ProviderKey, + type HealthCheckResult, + type HealthCheckSpec, type OAuthClientSummary, type Owner, } from "@executor-js/sdk/shared"; @@ -17,15 +19,25 @@ import { toast } from "sonner"; import { addConnectionOptimistic, connectionsAllAtom, + integrationHealthCheckAtom, + integrationHealthCheckCandidatesAtom, oauthClientsOptimisticAtom, probeOAuth, providerItemsAtom, providersAtom, registerDynamicOAuthClient, removeOAuthClientOptimistic, + setIntegrationHealthCheck, startOAuth, + validateConnection, } from "../api/atoms"; -import { connectionWriteKeys, oauthClientWriteKeys } from "../api/reactivity-keys"; +import { + connectionWriteKeys, + healthCheckWriteKeys, + oauthClientWriteKeys, +} from "../api/reactivity-keys"; +import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display"; +import { HealthCheckConfigFields } from "./health-check-editor"; import { messageFromExit } from "../api/error-reporting"; import { trackEvent } from "../api/analytics"; import { useOrganizationId } from "../api/organization-context"; @@ -619,6 +631,38 @@ export function AddAccountModal(props: AddAccountModalProps) { return props.open ? : null; } +// --------------------------------------------------------------------------- +// Key-check status: the inline line under the credential field that reports the +// live probe result. `validating` shows a neutral "Checking..."; a result shows +// the status dot + label, and any upstream detail for a non-healthy verdict. +// --------------------------------------------------------------------------- +function KeyValidationStatus(props: { + readonly validating: boolean; + readonly result: HealthCheckResult | null; +}) { + if (props.validating) { + return ( +

+ + Checking the key... +

+ ); + } + if (!props.result) return null; + const { status, detail } = props.result; + const indicator = HEALTH_INDICATOR_COLOR[status]; + const tone = status === "healthy" ? "text-muted-foreground" : "text-destructive"; + return ( +
+ + + {HEALTH_STATUS_LABEL[status]} + {status !== "healthy" && detail ? {detail} : null} + +
+ ); +} + function AddAccountModalView(props: AddAccountModalProps) { const { integration, @@ -662,6 +706,15 @@ function AddAccountModalView(props: AddAccountModalProps) { const [credentialOrigin, setCredentialOrigin] = useState("paste"); const [onePasswordItemId, setOnePasswordItemId] = useState(""); const [label, setLabel] = useState(""); + // Key check: the in-flight probe state and its last result. When the + // integration has no configured health check, `hcOperation`/`hcArgs` hold an + // inline-drafted check the user picks to test the key against (and which we + // save as the integration's check once it comes back healthy). All of this + // lives in the view, so closing the modal unmounts and resets it. + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [hcOperation, setHcOperation] = useState(""); + const [hcArgs, setHcArgs] = useState>({}); // Explicit create-time choice (no ambient owner). Cloud defaults to Personal; // local/desktop hide the picker and save to the one local workspace. const [owner, setOwner] = useState(defaultOwner); @@ -692,6 +745,19 @@ function AddAccountModalView(props: AddAccountModalProps) { mode: "promiseExit", }); const doRemoveOAuthClient = useAtomSet(removeOAuthClientOptimistic, { mode: "promise" }); + const doValidate = useAtomSet(validateConnection, { mode: "promiseExit" }); + const doSetHealthCheck = useAtomSet(setIntegrationHealthCheck, { mode: "promiseExit" }); + + // The integration's declared health check + its candidate operations. When a + // check is configured we probe against it; when not, the user picks one of the + // candidates inline to test the key (and we save it). + const healthCheckResult = useAtomValue(integrationHealthCheckAtom(integration)); + const hasHealthCheck = + AsyncResult.isSuccess(healthCheckResult) && healthCheckResult.value !== null; + const candidatesResult = useAtomValue(integrationHealthCheckCandidatesAtom(integration)); + const healthCheckCandidates = AsyncResult.isSuccess(candidatesResult) + ? candidatesResult.value + : []; // Full registered-app summaries (carry endpoints + resource the picker's // lightweight options omit) and the connection→app usage map that powers the @@ -791,6 +857,21 @@ function AddAccountModalView(props: AddAccountModalProps) { const isOAuth = method?.kind === "oauth"; const isNoAuth = method?.kind === "none"; + + // Key check (pasteable credentials only): offer it whenever the integration + // either has a configured check OR exposes candidate operations to test + // against. The inline-picked candidate (when no check is configured) drives + // both the probe and what we save as the integration's health check. + const hcSelected = healthCheckCandidates.find((c) => c.operation === hcOperation) ?? null; + const hcRequiredParams = (hcSelected?.parameters ?? []).filter((p) => p.required); + const hcMissingRequired = hcRequiredParams.some( + (p) => (hcArgs[p.name] ?? "").trim().length === 0, + ); + const canCheckKey = !isOAuth && !isNoAuth && (hasHealthCheck || healthCheckCandidates.length > 0); + // True when we have something to probe against: a configured check, or a + // complete inline-picked candidate. + const keyCheckReady = hasHealthCheck || (hcOperation.length > 0 && !hcMissingRequired); + // The distinct credential inputs the selected method needs — one per variable // across its placements. A single-input method yields one field (`token`); a // multi-input method (e.g. Datadog) yields one per key. Two placements sharing @@ -1004,6 +1085,69 @@ function AddAccountModalView(props: AddAccountModalProps) { close(); }; + // A pasted credential (or the picked operation) changed; the prior verdict is + // stale. Clear it so the status line doesn't show a result for a key/operation + // that is no longer what's in the form. + const clearKeyCheck = (): void => { + if (validationResult !== null) setValidationResult(null); + }; + + // Check the key works: probe the pasted credential WITHOUT saving the + // connection. When the integration has a configured health check we run it; + // otherwise we run the inline-picked candidate and, if it comes back healthy, + // save it as the integration's health check (so it's configured "then"). + const handleValidate = async () => { + const payloadOrigin = createCredentialPayloadOrigin({ + origin: credentialOrigin, + inputs: credentialInputs, + values, + onePasswordItemId, + singleInput, + }); + if (!method || payloadOrigin === null || validating) return; + let inlineSpec: HealthCheckSpec | undefined; + if (!hasHealthCheck) { + if (hcOperation.length === 0 || hcMissingRequired) return; + const argEntries = Object.entries(hcArgs) + .map(([key, value]) => [key, value.trim()] as const) + .filter(([, value]) => value.length > 0); + inlineSpec = { + operation: hcOperation, + ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), + }; + } + setValidating(true); + const exit = await doValidate({ + payload: { + owner, + integration, + template: method.template, + ...(inlineSpec ? { spec: inlineSpec } : {}), + ...("from" in payloadOrigin + ? { from: payloadOrigin.from } + : { values: payloadOrigin.values }), + }, + }); + setValidating(false); + if (Exit.isFailure(exit)) { + setValidationResult(null); + toast.error(messageFromExit(exit, "Couldn't check the key")); + return; + } + const result = exit.value; + setValidationResult(result); + // Set it "then": a freshly-picked operation that probes healthy becomes the + // integration's health check, so the editor + status surfaces pick it up. + if (inlineSpec && result.status === "healthy") { + const saved = await doSetHealthCheck({ + params: { slug: integration }, + payload: { spec: inlineSpec }, + reactivityKeys: healthCheckWriteKeys, + }); + if (Exit.isSuccess(saved)) toast.success("Saved as this integration's health check"); + } + }; + const handleOAuthConnect = async () => { if (!method || !chosenClient) return; // The connection is minted under the user-picked "saved to" owner, NOT the @@ -1466,19 +1610,82 @@ function AddAccountModalView(props: AddAccountModalProps) { ) ) : ( - { - setCredentialOrigin(next); - if (next === "paste") setOnePasswordItemId(""); - }} - onePasswordItemId={onePasswordItemId} - onOnePasswordItemIdChange={setOnePasswordItemId} - /> +
+ { + setValues(next); + clearKeyCheck(); + }} + origin={credentialOrigin} + onOriginChange={(next) => { + setCredentialOrigin(next); + if (next === "paste") setOnePasswordItemId(""); + clearKeyCheck(); + }} + onePasswordItemId={onePasswordItemId} + onOnePasswordItemIdChange={(next) => { + setOnePasswordItemId(next); + clearKeyCheck(); + }} + /> + {/* Check the key works before saving. Runs the + integration's health check, or an operation you pick + here (which we then save as the check). */} + {canCheckKey ? ( +
+ {!hasHealthCheck ? ( +
+

+ Pick a read-only operation to test the key against. We save + it as this integration's health check. +

+ { + setHcOperation(next); + setHcArgs({}); + clearKeyCheck(); + }} + args={hcArgs} + onArgChange={(name, value) => { + setHcArgs((prev) => ({ ...prev, [name]: value })); + clearKeyCheck(); + }} + disabled={validating} + idPrefix="connect-health-check" + /> +
+ ) : null} +
+ + + Confirms the key authenticates. + +
+ +
+ ) : null} +
)} {isOAuth && oauthPopup.error ? (

{oauthPopup.error}

diff --git a/packages/react/src/components/combobox.tsx b/packages/react/src/components/combobox.tsx index 09042d35e..e1091327b 100644 --- a/packages/react/src/components/combobox.tsx +++ b/packages/react/src/components/combobox.tsx @@ -103,7 +103,11 @@ function ComboboxContent({ align={align} alignOffset={alignOffset} anchor={anchor} - className="isolate z-50" + // `pointer-events-auto` re-enables interaction when the popup is portaled + // out of a modal dialog (Radix sets `pointer-events: none` on the body + // for modal dialogs/sheets; without this the portaled list can't be + // scrolled or clicked). Harmless outside a dialog (auto is the default). + className="isolate z-50 pointer-events-auto" > void; @@ -277,25 +286,62 @@ function FreeformCombobox(props: { readonly className?: string; readonly inputClassName?: string; readonly disabled?: boolean; + /** Forwarded to the underlying text input so a `