diff --git a/.changeset/describe-compile-scope.md b/.changeset/describe-compile-scope.md new file mode 100644 index 0000000000..a57cd5853c --- /dev/null +++ b/.changeset/describe-compile-scope.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Compile `describe.tool` previews against only the definitions a tool references, drop the compiler's per-call retained graph, and fall back to `unknown` for schemas over a node limit. Describing a tool from a large OpenAPI spec no longer burns seconds of CPU or leaks memory in the shared session isolate. diff --git a/.changeset/lost-execution-visibility.md b/.changeset/lost-execution-visibility.md new file mode 100644 index 0000000000..28e783ab13 --- /dev/null +++ b/.changeset/lost-execution-visibility.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Report an MCP `execute` call that dies with a session reset as a JSON-RPC error instead of a silently closed stream. The front worker answers outstanding request ids when the session socket closes abnormally or a response deadline passes, and a rebuilt session answers ids stranded by a previous incarnation on the next stream. The plain memory-limit reset is now classified as transient. diff --git a/.changeset/openapi-transport-unreachable.md b/.changeset/openapi-transport-unreachable.md new file mode 100644 index 0000000000..8921b9b31f --- /dev/null +++ b/.changeset/openapi-transport-unreachable.md @@ -0,0 +1,6 @@ +--- +"executor": patch +"@executor-js/plugin-openapi": patch +--- + +OpenAPI tools that cannot reach the upstream server now return an `upstream_unreachable` error with an actionable network message instead of `Internal tool error [id]`. diff --git a/.changeset/openapi-vendor-json-content-type.md b/.changeset/openapi-vendor-json-content-type.md new file mode 100644 index 0000000000..24c4da544e --- /dev/null +++ b/.changeset/openapi-vendor-json-content-type.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Preserve vendor +json Content-Type on OpenAPI object request bodies. diff --git a/.changeset/self-hosted-fonts.md b/.changeset/self-hosted-fonts.md new file mode 100644 index 0000000000..4d8e025451 --- /dev/null +++ b/.changeset/self-hosted-fonts.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Bundle the Geist and Geist Mono fonts with the console instead of loading them from Google Fonts. The UI no longer stays blank when a self-hosted deployment cannot reach fonts.googleapis.com. diff --git a/.changeset/toolkits-empty-grid.md b/.changeset/toolkits-empty-grid.md new file mode 100644 index 0000000000..fa868f391f --- /dev/null +++ b/.changeset/toolkits-empty-grid.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Prevent the empty Toolkits page from scrolling past its visible add cards. diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts index 3d4ad76343..584b65ee0e 100644 --- a/apps/cloud/src/mcp/session-build-semaphore.test.ts +++ b/apps/cloud/src/mcp/session-build-semaphore.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from "@effect/vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest"; import { acquireBuildSlot, @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => { resetBuildSlotsForTest(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("grants up to the cap immediately, with no wait", async () => { const results = await Promise.all([ acquireBuildSlot().promise, @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => { }); it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => { + vi.useFakeTimers(); await Promise.all([ acquireBuildSlot().promise, acquireBuildSlot().promise, @@ -223,6 +228,10 @@ describe("session-build-semaphore", () => { expect(currentActiveBuildsForTest()).toBe(4); const timedOutHandle = acquireBuildSlot(10); + await vi.advanceTimersByTimeAsync(9); + expect(currentQueueLengthForTest()).toBe(1); + expect(currentActiveBuildsForTest()).toBe(4); + await vi.advanceTimersByTimeAsync(1); const result = await timedOutHandle.promise; expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true }); diff --git a/apps/cloud/src/observability/observability.test.ts b/apps/cloud/src/observability/observability.test.ts index b95ea61d7a..581377d12f 100644 --- a/apps/cloud/src/observability/observability.test.ts +++ b/apps/cloud/src/observability/observability.test.ts @@ -385,15 +385,24 @@ describe("Durable Object platform reset noise", () => { expect(beforeSendWithOtelCorrelation(defect)).not.toBeNull(); }); - // The memory-limit reset is deliberately absent from the classifier: the - // runtime blames the application for it, so it is a defect, not noise. - it("keeps the memory-limit reset the classifier deliberately excludes", () => { + // The storage-cache memory-limit variant stays absent from the classifier: + // the runtime blames the application for it (un-awaited writes, an oversized + // read), so it is a defect, not noise. Its plain sibling is a platform reset + // and IS classified — the two are separated only by that qualifier. + it("keeps the memory-limit variant the classifier deliberately excludes", () => { const memory = doInstrumentationEvent( "Durable Object's isolate exceeded its memory limit due to overflowing the storage cache. All objects in the isolate were reset.", ); expect(beforeSendWithOtelCorrelation(memory)).not.toBeNull(); }); + it("drops the plain memory-limit reset as platform noise", () => { + const memory = doInstrumentationEvent( + "Durable Object's isolate exceeded its memory limit and was reset.", + ); + expect(beforeSendWithOtelCorrelation(memory)).toBeNull(); + }); + it("the hook the worker and DOs install drops the deploy reset", () => { const options = cloudSentryOptions({ SENTRY_DSN: "https://public@example.invalid/1" } as Env); const event = doInstrumentationEvent("Durable Object reset because its code was updated."); diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 7f141e6072..1c0f8b8a10 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -116,12 +116,6 @@ export const Route = createRootRoute({ { rel: "icon", type: "image/png", sizes: "32x32", href: "/favicon-32.png" }, { rel: "icon", type: "image/png", sizes: "192x192", href: "/favicon-192.png" }, { rel: "apple-touch-icon", sizes: "180x180", href: "/apple-touch-icon.png" }, - { rel: "preconnect", href: "https://fonts.googleapis.com" }, - { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }, - { - rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;700&display=swap", - }, { rel: "stylesheet", href: appCss }, ], }), diff --git a/apps/host-cloudflare/web/index.html b/apps/host-cloudflare/web/index.html index df417e4c33..82e1575dc3 100644 --- a/apps/host-cloudflare/web/index.html +++ b/apps/host-cloudflare/web/index.html @@ -8,12 +8,6 @@ Executor - - -
diff --git a/apps/host-selfhost/web/index.html b/apps/host-selfhost/web/index.html index b3260fdb8d..1d82586ac9 100644 --- a/apps/host-selfhost/web/index.html +++ b/apps/host-selfhost/web/index.html @@ -8,12 +8,6 @@ Executor (self-hosted) - - -
diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 71ad6c4f41..2184b4ec12 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -197,7 +197,10 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo > - Read docs + Read docs diff --git a/e2e/scenarios/connection-remove-confirm.test.ts b/e2e/scenarios/connection-remove-confirm.test.ts index b0c8ab8146..736d8740ca 100644 --- a/e2e/scenarios/connection-remove-confirm.test.ts +++ b/e2e/scenarios/connection-remove-confirm.test.ts @@ -41,7 +41,7 @@ scenario( const client = yield* makeClient(api, identity); const slug = IntegrationSlug.make(`rm-confirm-${randomBytes(4).toString("hex")}`); - const name = ConnectionName.make("main"); + const name = ConnectionName.make("longconnectionnamethatmustwrapwithoutoverflow"); yield* Effect.ensuring( Effect.gen(function* () { @@ -73,9 +73,10 @@ scenario( const connections = page.locator("section").filter({ has: page.getByRole("heading", { level: 3, name: "Connections" }), }); - const row = connections.getByText("main", { exact: true }); + const row = connections.getByText(String(name), { exact: true }); const menuTrigger = connections.locator('button[aria-haspopup="menu"]'); const confirm = page.getByRole("alertdialog"); + const removeAction = confirm.getByRole("button", { name: "Remove connection" }); await step("Open the integration's connections", async () => { await visit(page, `/integrations/${slug}`); @@ -85,7 +86,24 @@ scenario( await step("Remove asks for confirmation instead of firing", async () => { await menuTrigger.click(); await page.getByRole("menuitem", { name: "Remove" }).click(); - await confirm.getByText("Remove main?").waitFor(); + const title = confirm.getByText(`Remove ${String(name)}?`); + await title.waitFor(); + await removeAction.getByText("Remove", { exact: true }).waitFor(); + + const layout = await confirm.evaluate((dialog) => { + const title = dialog.querySelector('[data-slot="alert-dialog-title"]'); + if (title === null) return null; + const titleText = document.createRange(); + titleText.selectNodeContents(title); + return { + dialogFits: dialog.scrollWidth <= dialog.clientWidth + 1, + titleWraps: titleText.getClientRects().length > 1, + }; + }); + expect(layout, "the confirmation title wraps without widening the dialog").toEqual({ + dialogFits: true, + titleWraps: true, + }); }); await step("Cancel keeps the connection", async () => { @@ -97,7 +115,7 @@ scenario( await step("Confirming actually removes it", async () => { await menuTrigger.click(); await page.getByRole("menuitem", { name: "Remove" }).click(); - await confirm.getByRole("button", { name: "Remove connection" }).click(); + await removeAction.click(); await confirm.waitFor({ state: "detached" }); await row.waitFor({ state: "detached" }); }); diff --git a/e2e/scenarios/openapi-unreachable-artifact.test.ts b/e2e/scenarios/openapi-unreachable-artifact.test.ts new file mode 100644 index 0000000000..179ca5048d --- /dev/null +++ b/e2e/scenarios/openapi-unreachable-artifact.test.ts @@ -0,0 +1,226 @@ +// Cross-target: an artifact whose OpenAPI query cannot reach its upstream gets +// an actionable network error, not the opaque defect mask. This walks the real +// path from a saved artifact through the nested shell, execute-action, sandbox, +// OpenAPI transport, and back into ArtifactError. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { Page } from "playwright"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { ConnectionName, IntegrationSlug, type ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; +import type { McpSession } from "../src/surfaces/mcp"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +type DroppingUpstream = { + readonly url: string; + readonly requests: () => number; + readonly close: () => void; +}; + +// Accept the request, then drop the socket before sending response headers. +// This produces a real transport failure without relying on a hardcoded or +// temporarily-unused port. +const serveDroppingUpstream = () => + Effect.acquireRelease( + Effect.callback((resume) => { + let hits = 0; + const server = createServer((_request, response) => { + hits += 1; + response.destroy(); + }); + 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}`, + requests: () => hits, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +const unreachableSpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Unreachable API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/things": { + get: { + tags: ["things"], + operationId: "listThings", + summary: "List things", + responses: { + "200": { + description: "Things", + content: { + "application/json": { + schema: { type: "array", items: { type: "object" } }, + }, + }, + }, + }, + }, + }, + }, + }); + +const createConnectionCode = (slug: string) => ` +const created = await tools.executor.coreTools.connections.create({ + owner: "org", + name: "public", + integration: ${JSON.stringify(slug)}, + template: "none", +}); +return JSON.stringify(created.ok ? { ok: true } : { ok: false, error: created.error }); +`; + +const executeApproved = (session: McpSession, code: string) => + Effect.gen(function* () { + let result = yield* session.call("execute", { code }); + let guard = 0; + while (result.text.includes("executionId:") && guard < 10) { + result = yield* session.approvePaused(result.text); + guard += 1; + } + expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); + return result.text; + }); + +const artifactSource = (slug: string) => ` +function App() { + const query = useQuery(tools.${slug}.things.listThings.queryOptions({})); + const result = query.data; + return ( +
+

Upstream status

+
+ {query.isLoading ? ( + + ) : query.error ? ( + + ) : result?.ok === false ? ( + + ) : ( +

Unexpected upstream success

+ )} +
+
+ ); +} +`; + +const structuredOf = (result: { readonly raw: unknown }): Record => + ((result.raw as { structuredContent?: Record }).structuredContent ?? + {}) as Record; + +const artifactContent = (page: Page) => + page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe"); + +scenario( + "Artifacts · an unreachable OpenAPI host shows actionable retry guidance instead of an internal error", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const session = mcp.session(identity); + const upstream = yield* serveDroppingUpstream(); + const slug = unique("unreachable"); + const title = `Unreachable upstream ${randomBytes(4).toString("hex")}`; + let artifactId: ArtifactId | undefined; + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: unreachableSpec(upstream.url) }, + slug, + baseUrl: upstream.url, + }, + }); + + const created = yield* executeApproved(session, createConnectionCode(slug)); + expect(created, `the no-auth connection was created: ${created}`).toContain('"ok":true'); + + const rendered = yield* session.call("create-artifact", { + code: artifactSource(slug), + title, + description: "Shows whether the upstream API is reachable", + connections: { [slug]: `${slug}.org.public` }, + }); + expect(rendered.ok, `create-artifact succeeded: ${rendered.text}`).toBe(true); + + const structured = structuredOf(rendered); + artifactId = structured.artifactId as ArtifactId; + expect(artifactId, "the artifact was persisted").toBeTruthy(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the artifact that reads from the unreachable API", async () => { + await visit(page, String(structured.url)); + await page.getByRole("heading", { name: title }).waitFor({ timeout: 20_000 }); + }); + + await step( + "The artifact explains that the upstream host could not be reached", + async () => { + const state = artifactContent(page).getByTestId("upstream-state"); + await state.locator('[data-slot="artifact-error"]').waitFor({ timeout: 30_000 }); + const message = await state.innerText(); + + expect(message, "the user gets actionable network guidance").toContain( + "Could not reach the upstream server", + ); + expect(message, "the opaque defect mask never reaches the artifact").not.toContain( + "Internal tool error", + ); + expect(message, "the request path is not leaked").not.toContain("/things"); + }, + ); + }); + + expect(upstream.requests(), "the artifact made a real upstream request").toBeGreaterThan( + 0, + ); + }), + Effect.gen(function* () { + if (artifactId !== undefined) { + yield* client.artifacts.remove({ params: { artifactId } }).pipe(Effect.ignore); + } + yield* client.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: ConnectionName.make("public"), + }, + }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/e2e/selfhost/toolkits-empty-grid.test.ts b/e2e/selfhost/toolkits-empty-grid.test.ts new file mode 100644 index 0000000000..41fe93091b --- /dev/null +++ b/e2e/selfhost/toolkits-empty-grid.test.ts @@ -0,0 +1,56 @@ +// An empty Toolkits grid must fit the viewport: with no toolkits in either +// scope, both the Workspace and Personal add cards sit above the fold and the +// grid does not scroll. Regression: each shelf reserved a fixed ~3-row +// min-height, so two empty shelves stacked past the viewport and the page +// scrolled with nothing to see. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +scenario( + "Toolkits · empty grid fits the viewport without scrolling", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Toolkits page with no toolkits in either scope", async () => { + await visit(page, "/default/toolkits/"); + await page.getByRole("heading", { name: "Toolkits", level: 1 }).waitFor(); + await page.getByRole("heading", { name: "Workspace" }).waitFor(); + await page.getByRole("heading", { name: "Personal" }).waitFor(); + await page.getByRole("button", { name: "Add workspace toolkit" }).waitFor(); + await page.getByRole("button", { name: "Add personal toolkit" }).waitFor(); + await page.locator('main [data-slot="skeleton"]').first().waitFor({ state: "detached" }); + }); + + await step("The empty grid does not overflow its scroll container", async () => { + // Walk up from the Personal add card to its nearest scrollable + // ancestor; on an empty grid that ancestor must have nothing to + // scroll. This is the user-visible contract — both add cards are + // reachable without scrolling — measured at the scroll boundary. + const overflow = await page.evaluate(() => { + const addCard = [...document.querySelectorAll("button")].find( + (button) => button.getAttribute("aria-label") === "Add personal toolkit", + ); + let node: HTMLElement | null = addCard ?? null; + while (node && node.scrollHeight <= node.clientHeight + 1) { + node = node.parentElement; + } + if (!node) return null; + return { + tag: node.tagName, + scrollHeight: node.scrollHeight, + clientHeight: node.clientHeight, + }; + }); + expect(overflow, "no scrollable ancestor overflows for an empty grid").toBeNull(); + }); + }); + }), +); diff --git a/packages/app/index.html b/packages/app/index.html index 4255a12a78..b9ba153f8d 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -8,12 +8,6 @@ Executor - - -