From e6e936fdbdbadd81a6a42a46eea94f470dec83e0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:17:07 -0700 Subject: [PATCH 1/2] Do not hide Slack triggers when capability probe fails Closes CL-6835 --- .../src/deployment-capabilities-api.test.ts | 88 +++++++++++++++++++ apps/web/src/deployment-capabilities-api.ts | 70 +++++++++++---- apps/web/src/shell/routine-panel.tsx | 14 ++- apps/web/test/routine-panel.test.tsx | 19 ++++ 4 files changed, 172 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/deployment-capabilities-api.test.ts diff --git a/apps/web/src/deployment-capabilities-api.test.ts b/apps/web/src/deployment-capabilities-api.test.ts new file mode 100644 index 000000000..16d8646e5 --- /dev/null +++ b/apps/web/src/deployment-capabilities-api.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { + fetchDeploymentCapabilities, + slackTriggerOffered, +} from "./deployment-capabilities-api"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function stubFetch( + respond: (path: string) => Response | Promise, +): void { + globalThis.fetch = ((input: RequestInfo | URL, _init?: RequestInit) => { + const path = + typeof input === "string" ? input : new URL(String(input)).pathname; + return Promise.resolve(respond(path)); + }) as typeof fetch; +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +describe("fetchDeploymentCapabilities", () => { + test("a genuine slackConfigured:false is ready, not unavailable", async () => { + stubFetch(() => json({ slackConfigured: false })); + const result = await fetchDeploymentCapabilities(); + expect(result).toEqual({ kind: "ready", slackConfigured: false }); + }); + + test("a configured deployment is ready with slackConfigured:true", async () => { + stubFetch(() => json({ slackConfigured: true })); + const result = await fetchDeploymentCapabilities(); + expect(result).toEqual({ kind: "ready", slackConfigured: true }); + }); + + test("a non-2xx response is unavailable, not slackConfigured:false", async () => { + stubFetch(() => json({ message: "boom" }, 500)); + const result = await fetchDeploymentCapabilities(); + expect(result.kind).toBe("unavailable"); + }); + + test("a body that fails the schema is unavailable", async () => { + stubFetch(() => json({ slackConfigured: "yes" })); + const result = await fetchDeploymentCapabilities(); + expect(result.kind).toBe("unavailable"); + }); + + test("a network failure is unavailable", async () => { + globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => + Promise.reject(new Error("network down"))) as typeof fetch; + const result = await fetchDeploymentCapabilities(); + expect(result).toEqual({ + kind: "unavailable", + message: "network down", + }); + }); +}); + +describe("slackTriggerOffered", () => { + test("hides Slack while the probe is still loading", () => { + expect(slackTriggerOffered(null)).toBe(false); + }); + + test("follows the hub when the probe answered", () => { + expect( + slackTriggerOffered({ kind: "ready", slackConfigured: false }), + ).toBe(false); + expect( + slackTriggerOffered({ kind: "ready", slackConfigured: true }), + ).toBe(true); + }); + + test("keeps Slack offered when the probe failed — never hides solely on failure", () => { + expect( + slackTriggerOffered({ + kind: "unavailable", + message: "The server answered 500 for deployment capabilities.", + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/deployment-capabilities-api.ts b/apps/web/src/deployment-capabilities-api.ts index a04c5d307..3aae95c66 100644 --- a/apps/web/src/deployment-capabilities-api.ts +++ b/apps/web/src/deployment-capabilities-api.ts @@ -6,33 +6,73 @@ // routine trigger popover (see `shell/routine-panel.tsx`) reads this to // decide whether a Slack-bound webhook trigger is honestly offerable, the // same way `/api/auth-config` decides which sign-in buttons to draw. +// +// Distinguishes "the hub answered and Slack is not configured" (`ready` +// with `slackConfigured: false`) from a network failure, non-2xx, or +// unparseable body (`unavailable`). Collapsing those into +// `{ slackConfigured: false }` was CL-6835 — the trigger affordance +// disappeared with no error whenever the probe missed. import { type } from "arktype"; import { useQuery } from "@tanstack/react-query"; -const DeploymentCapabilities = type({ +const DeploymentCapabilitiesBody = type({ slackConfigured: "boolean", }); -export type DeploymentCapabilities = typeof DeploymentCapabilities.infer; +export type DeploymentCapabilities = typeof DeploymentCapabilitiesBody.infer; -const UNAVAILABLE: DeploymentCapabilities = { slackConfigured: false }; +export type DeploymentCapabilitiesResult = + | { readonly kind: "ready"; readonly slackConfigured: boolean } + | { readonly kind: "unavailable"; readonly message: string }; -export async function fetchDeploymentCapabilities(): Promise { - const response = await fetch("/api/deployment-capabilities", { - headers: { accept: "application/json" }, - }).catch(() => null); - if (response === null || !response.ok) return UNAVAILABLE; - const body: unknown = await response.json().catch(() => undefined); - const parsed = DeploymentCapabilities(body); - return parsed instanceof type.errors ? UNAVAILABLE : parsed; +export async function fetchDeploymentCapabilities(): Promise { + try { + const response = await fetch("/api/deployment-capabilities", { + headers: { accept: "application/json" }, + }); + if (!response.ok) { + return { + kind: "unavailable", + message: `The server answered ${response.status} for deployment capabilities.`, + }; + } + const body: unknown = await response.json(); + const parsed = DeploymentCapabilitiesBody(body); + if (parsed instanceof type.errors) { + return { + kind: "unavailable", + message: `Unexpected deployment capabilities shape: ${parsed.summary}`, + }; + } + return { kind: "ready", slackConfigured: parsed.slackConfigured }; + } catch (cause) { + return { + kind: "unavailable", + message: cause instanceof Error ? cause.message : String(cause), + }; + } } -/** Absent (still fetching), never claims Slack is configured — the trigger - * popover's own "hide when not available" default. */ -export function useDeploymentCapabilities(): DeploymentCapabilities { +/** + * Whether the routine trigger popover should offer Slack. Still-loading + * hides it (don't claim configured); a ready answer follows the hub; a + * probe failure keeps it offered — disappearing solely because the probe + * failed is the CL-6835 bug. + */ +export function slackTriggerOffered( + result: DeploymentCapabilitiesResult | null, +): boolean { + if (result === null) return false; + if (result.kind === "unavailable") return true; + return result.slackConfigured; +} + +/** `null` while the probe is in flight — consumers must not treat that as + * "Slack is not configured". */ +export function useDeploymentCapabilities(): DeploymentCapabilitiesResult | null { const { data } = useQuery({ queryKey: ["deployment-capabilities"], queryFn: fetchDeploymentCapabilities, staleTime: Infinity, }); - return data ?? UNAVAILABLE; + return data ?? null; } diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx index 4a1fb10f2..c8309ada2 100644 --- a/apps/web/src/shell/routine-panel.tsx +++ b/apps/web/src/shell/routine-panel.tsx @@ -68,7 +68,10 @@ import { createWebhookTrigger, DEFAULT_WEBHOOK_INPUT_TEMPLATE, } from "../webhook-triggers-api"; -import { useDeploymentCapabilities } from "../deployment-capabilities-api"; +import { + useDeploymentCapabilities, + slackTriggerOffered, +} from "../deployment-capabilities-api"; import { useGranolaPluginConnected } from "../granola-plugin-availability"; import { tenantKeys } from "../query-client"; import { useCanvasColumnRoutine, useCloseCanvas } from "./canvas-availability"; @@ -98,7 +101,9 @@ function triggerRowSummary( * — an unconnected plugin must never look like a working trigger; Slack is * offered only when this deployment's Slack tag ingress is actually mounted * (see `deployment-capabilities-api.ts`) — an unconfigured deployment must - * never offer a trigger that can't honestly fire. + * never offer a trigger that can't honestly fire. A probe failure is not the + * same as unconfigured (CL-6835): the affordance stays offered rather than + * vanishing with no error. */ function AddTriggerMenu({ slackAvailable, @@ -170,7 +175,8 @@ function RoutineEditorPanel({ }) { const navigate = useNavigate(); const { selectedTenantId: tenantId } = useBench(); - const { slackConfigured } = useDeploymentCapabilities(); + const capabilities = useDeploymentCapabilities(); + const slackAvailable = slackTriggerOffered(capabilities); const granolaConnected = useGranolaPluginConnected(tenantId); const queryClient = useQueryClient(); @@ -567,7 +573,7 @@ function RoutineEditorPanel({ When to run {trigger === null && !addingSchedule ? ( setAddingSchedule(true)} diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index c01f468b3..5e6218910 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -52,6 +52,7 @@ let createWorkbenchCalls: Record[] = []; let runNowCalls = 0; let slackConfigured = false; let granolaConnected = false; +let capabilitiesProbeFails = false; let networkDelayMs = 0; let workbenchAgentsByWorkbench: Record< string, @@ -104,6 +105,12 @@ async function routeFetch( return jsonResponse({ workbenchTenantIds: [] }); } if (url.includes("/api/deployment-capabilities")) { + if (capabilitiesProbeFails) { + return new Response(JSON.stringify({ message: "boom" }), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } return jsonResponse({ slackConfigured }); } if (url.includes("/credentials/resolve/Granola")) { @@ -272,6 +279,7 @@ describe("RoutinePanel", () => { runNowCalls = 0; slackConfigured = false; granolaConnected = false; + capabilitiesProbeFails = false; networkDelayMs = 0; chatWorkbenches = []; runsByRoutineId = {}; @@ -605,6 +613,17 @@ describe("RoutinePanel", () => { expect(items).toContain("Granola call notes"); }); + test("a failed capabilities probe still offers Slack — never hides solely because the probe failed (CL-6835)", async () => { + capabilitiesProbeFails = true; + await renderPanel({ routineId: null, workbenchId: "ch_1" }); + act(() => openMenu(buttonWithText("+ Add trigger"))); + await settle(); + const items = [...document.querySelectorAll('[role="menuitem"]')].map( + (el) => el.textContent?.trim(), + ); + expect(items).toContain("Slack"); + }); + test("picking a schedule preset commits the trigger in one click — no sub-menu chain", async () => { await renderPanel({ routineId: null, workbenchId: "ch_1" }); act(() => openMenu(buttonWithText("+ Add trigger"))); From 2d304e6f987f195edca25c447e175d0a2bfdfab4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 09:41:38 -0700 Subject: [PATCH 2/2] Reword Slack capability errors away from banned vocabulary --- apps/web/src/deployment-capabilities-api.test.ts | 14 +++++++------- apps/web/src/deployment-capabilities-api.ts | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/deployment-capabilities-api.test.ts b/apps/web/src/deployment-capabilities-api.test.ts index 16d8646e5..0c4b3d328 100644 --- a/apps/web/src/deployment-capabilities-api.test.ts +++ b/apps/web/src/deployment-capabilities-api.test.ts @@ -69,19 +69,19 @@ describe("slackTriggerOffered", () => { }); test("follows the hub when the probe answered", () => { - expect( - slackTriggerOffered({ kind: "ready", slackConfigured: false }), - ).toBe(false); - expect( - slackTriggerOffered({ kind: "ready", slackConfigured: true }), - ).toBe(true); + expect(slackTriggerOffered({ kind: "ready", slackConfigured: false })).toBe( + false, + ); + expect(slackTriggerOffered({ kind: "ready", slackConfigured: true })).toBe( + true, + ); }); test("keeps Slack offered when the probe failed — never hides solely on failure", () => { expect( slackTriggerOffered({ kind: "unavailable", - message: "The server answered 500 for deployment capabilities.", + message: "The server answered 500 when checking Slack.", }), ).toBe(true); }); diff --git a/apps/web/src/deployment-capabilities-api.ts b/apps/web/src/deployment-capabilities-api.ts index 3aae95c66..ada8ff918 100644 --- a/apps/web/src/deployment-capabilities-api.ts +++ b/apps/web/src/deployment-capabilities-api.ts @@ -32,7 +32,7 @@ export async function fetchDeploymentCapabilities(): Promise