From 6f36b45eb2a6691c6ec28eea7398fbbb5bdb82f6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:41:15 -0700 Subject: [PATCH 1/3] CL-6759: Hide Granola trigger when Granola is not connected Offer Granola call notes in Add trigger only when the tenant has a connected Granola credential; keep schedule always and Slack gated on deployment capabilities. --- .../src/granola-plugin-availability.test.ts | 66 +++++++++++++++++++ apps/web/src/granola-plugin-availability.ts | 53 +++++++++++++++ apps/web/src/shell/routine-panel.tsx | 19 ++++-- apps/web/test/routine-panel.test.tsx | 44 +++++++++++++ 4 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/granola-plugin-availability.test.ts create mode 100644 apps/web/src/granola-plugin-availability.ts diff --git a/apps/web/src/granola-plugin-availability.test.ts b/apps/web/src/granola-plugin-availability.test.ts new file mode 100644 index 000000000..22ab16f56 --- /dev/null +++ b/apps/web/src/granola-plugin-availability.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { fetchGranolaPluginConnected } from "./granola-plugin-availability"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("fetchGranolaPluginConnected", () => { + test("false on 404 — Granola not connected", async () => { + globalThis.fetch = (async () => + new Response(null, { status: 404 })) as typeof fetch; + expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); + }); + + test("true when the resolved credential is active", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + id: "cred_1", + tenantId: "tnt_1", + name: "Granola", + status: "active", + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + expect(await fetchGranolaPluginConnected("tnt_1")).toBe(true); + }); + + test("true when the credential needs attention (expired/error)", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + id: "cred_1", + tenantId: "tnt_1", + name: "Granola", + status: "expired", + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + expect(await fetchGranolaPluginConnected("tnt_1")).toBe(true); + }); + + test("false when the credential is revoked", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + id: "cred_1", + tenantId: "tnt_1", + name: "Granola", + status: "revoked", + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); + }); + + test("false on network failure — never claim connected", async () => { + globalThis.fetch = (async () => { + throw new Error("offline"); + }) as typeof fetch; + expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); + }); +}); diff --git a/apps/web/src/granola-plugin-availability.ts b/apps/web/src/granola-plugin-availability.ts new file mode 100644 index 000000000..a88267fc2 --- /dev/null +++ b/apps/web/src/granola-plugin-availability.ts @@ -0,0 +1,53 @@ +// Whether this tenant has Granola connected — the routine trigger popover +// (see `shell/routine-panel.tsx`) reads this so an unconnected bench never +// offers "Granola call notes" as if it were a working trigger (CL-6759). +// Same honesty rule as Slack via `deployment-capabilities-api.ts`, but +// tenant-scoped: Granola's credential resolves through the ancestor chain +// (`GET /credentials/resolve/Granola`), not a deployment env gate. +import { type } from "arktype"; +import { useQuery } from "@tanstack/react-query"; + +import { tenantKeys } from "./query-client"; + +const ResolvedCredential = type({ + status: "'active' | 'expired' | 'revoked' | 'error'", +}); + +/** True when Granola is connected (or needs attention) for `tenantId`. A + * missing, revoked, or unreadable credential is not offerable. */ +export async function fetchGranolaPluginConnected( + tenantId: string, +): Promise { + const response = await fetch( + `/api/tenants/${tenantId}/credentials/resolve/${encodeURIComponent("Granola")}`, + { headers: { accept: "application/json" } }, + ).catch(() => null); + if (response === null || response.status === 404) return false; + if (!response.ok) return false; + const body: unknown = await response.json().catch(() => undefined); + const parsed = ResolvedCredential(body); + if (parsed instanceof type.errors) return false; + // Revoked reads as not connected — same rule as + // `@workbench/connections/plugins`' resolveOne. + return ( + parsed.status === "active" || + parsed.status === "expired" || + parsed.status === "error" + ); +} + +/** Absent (still fetching) never claims Granola is connected — the trigger + * popover's own "hide when not available" default. */ +export function useGranolaPluginConnected( + tenantId: string | null, +): boolean { + const { data } = useQuery({ + queryKey: + tenantId === null + ? (["granola-plugin-connected", "none"] as const) + : ([...tenantKeys.all(tenantId), "granola-plugin-connected"] as const), + queryFn: () => fetchGranolaPluginConnected(tenantId as string), + enabled: tenantId !== null, + }); + return data ?? false; +} diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx index be62407c1..4a1fb10f2 100644 --- a/apps/web/src/shell/routine-panel.tsx +++ b/apps/web/src/shell/routine-panel.tsx @@ -69,6 +69,7 @@ import { DEFAULT_WEBHOOK_INPUT_TEMPLATE, } from "../webhook-triggers-api"; import { useDeploymentCapabilities } from "../deployment-capabilities-api"; +import { useGranolaPluginConnected } from "../granola-plugin-availability"; import { tenantKeys } from "../query-client"; import { useCanvasColumnRoutine, useCloseCanvas } from "./canvas-availability"; import type { RoutinePanelSubject } from "./canvas-availability"; @@ -93,20 +94,22 @@ function triggerRowSummary( /** * `+ Add trigger` popover contents: schedule is always offered; Granola - * call notes is a plain inbound webhook binding (no external credential — - * Granola pushes to us), so it's always offered too; 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. + * call notes is offered only when this tenant has Granola connected (CL-6759) + * — 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. */ function AddTriggerMenu({ slackAvailable, + granolaAvailable, onSchedule, onGranola, onSlack, disabled, }: { readonly slackAvailable: boolean; + readonly granolaAvailable: boolean; readonly onSchedule: () => void; readonly onGranola: () => void; readonly onSlack: () => void; @@ -121,7 +124,9 @@ function AddTriggerMenu({ On a schedule › - Granola call notes + {granolaAvailable ? ( + Granola call notes + ) : null} {slackAvailable ? Slack : null} @@ -166,6 +171,7 @@ function RoutineEditorPanel({ const navigate = useNavigate(); const { selectedTenantId: tenantId } = useBench(); const { slackConfigured } = useDeploymentCapabilities(); + const granolaConnected = useGranolaPluginConnected(tenantId); const queryClient = useQueryClient(); const invalidateRoutines = () => { @@ -562,6 +568,7 @@ function RoutineEditorPanel({ {trigger === null && !addingSchedule ? ( setAddingSchedule(true)} onGranola={() => addWebhookTrigger("Granola call notes")} diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index 60a558b81..c01f468b3 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -51,6 +51,7 @@ let createRoutineCalls: Record[] = []; let createWorkbenchCalls: Record[] = []; let runNowCalls = 0; let slackConfigured = false; +let granolaConnected = false; let networkDelayMs = 0; let workbenchAgentsByWorkbench: Record< string, @@ -105,6 +106,20 @@ async function routeFetch( if (url.includes("/api/deployment-capabilities")) { return jsonResponse({ slackConfigured }); } + if (url.includes("/credentials/resolve/Granola")) { + if (!granolaConnected) { + return new Response(null, { status: 404 }); + } + return jsonResponse({ + id: "cred_granola", + tenantId: "tnt_1", + name: "Granola", + status: "active", + }); + } + if (url.includes("/credentials/resolve/")) { + return new Response(null, { status: 404 }); + } if (url.includes("/workflows/definitions")) { return jsonResponse({ data: [{ id: "wfd_myra", name: "assistant", status: "deployed" }], @@ -256,6 +271,7 @@ describe("RoutinePanel", () => { createWorkbenchCalls = []; runNowCalls = 0; slackConfigured = false; + granolaConnected = false; networkDelayMs = 0; chatWorkbenches = []; runsByRoutineId = {}; @@ -536,6 +552,7 @@ describe("RoutinePanel", () => { test("the trigger popover lists only honestly-working triggers — Slack hidden when not configured, shown when it is", async () => { slackConfigured = false; + granolaConnected = true; await renderPanel({ routineId: null, workbenchId: "ch_1" }); act(() => openMenu(buttonWithText("+ Add trigger"))); await settle(); @@ -561,6 +578,33 @@ describe("RoutinePanel", () => { expect(items).toContain("Slack"); }); + test("the trigger popover hides Granola call notes when Granola is not connected (CL-6759)", async () => { + granolaConnected = false; + await renderPanel({ routineId: null, workbenchId: "ch_1" }); + act(() => openMenu(buttonWithText("+ Add trigger"))); + await settle(); + let items = [...document.querySelectorAll('[role="menuitem"]')].map( + (el) => el.textContent?.trim(), + ); + expect(items).toContain("On a schedule ›"); + expect(items).not.toContain("Granola call notes"); + expect(items.every((label) => !label?.includes("Granola"))).toBe(true); + + act(() => root.unmount()); + container.remove(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + granolaConnected = true; + await renderPanel({ routineId: null, workbenchId: "ch_1" }); + act(() => openMenu(buttonWithText("+ Add trigger"))); + await settle(); + items = [...document.querySelectorAll('[role="menuitem"]')].map((el) => + el.textContent?.trim(), + ); + expect(items).toContain("Granola call notes"); + }); + 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 5bcdd6fadba0481c8eee9b420fa8c2522f139f2a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 09:41:11 -0700 Subject: [PATCH 2/3] Fix typecheck: fetch mock needs an unknown cast --- apps/web/src/granola-plugin-availability.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/granola-plugin-availability.test.ts b/apps/web/src/granola-plugin-availability.test.ts index 22ab16f56..fcb9ff397 100644 --- a/apps/web/src/granola-plugin-availability.test.ts +++ b/apps/web/src/granola-plugin-availability.test.ts @@ -11,7 +11,7 @@ afterEach(() => { describe("fetchGranolaPluginConnected", () => { test("false on 404 — Granola not connected", async () => { globalThis.fetch = (async () => - new Response(null, { status: 404 })) as typeof fetch; + new Response(null, { status: 404 })) as unknown as typeof fetch; expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); }); @@ -25,7 +25,7 @@ describe("fetchGranolaPluginConnected", () => { status: "active", }), { status: 200, headers: { "content-type": "application/json" } }, - )) as typeof fetch; + )) as unknown as typeof fetch; expect(await fetchGranolaPluginConnected("tnt_1")).toBe(true); }); @@ -39,7 +39,7 @@ describe("fetchGranolaPluginConnected", () => { status: "expired", }), { status: 200, headers: { "content-type": "application/json" } }, - )) as typeof fetch; + )) as unknown as typeof fetch; expect(await fetchGranolaPluginConnected("tnt_1")).toBe(true); }); @@ -53,14 +53,14 @@ describe("fetchGranolaPluginConnected", () => { status: "revoked", }), { status: 200, headers: { "content-type": "application/json" } }, - )) as typeof fetch; + )) as unknown as typeof fetch; expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); }); test("false on network failure — never claim connected", async () => { globalThis.fetch = (async () => { throw new Error("offline"); - }) as typeof fetch; + }) as unknown as typeof fetch; expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); }); }); From 76b2291a4bf85ae0d242eba9983e8c1b522f38ab Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 10:20:50 -0700 Subject: [PATCH 3/3] Format files changed in this PR --- apps/web/src/granola-plugin-availability.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/granola-plugin-availability.ts b/apps/web/src/granola-plugin-availability.ts index a88267fc2..644cad2b1 100644 --- a/apps/web/src/granola-plugin-availability.ts +++ b/apps/web/src/granola-plugin-availability.ts @@ -38,9 +38,7 @@ export async function fetchGranolaPluginConnected( /** Absent (still fetching) never claims Granola is connected — the trigger * popover's own "hide when not available" default. */ -export function useGranolaPluginConnected( - tenantId: string | null, -): boolean { +export function useGranolaPluginConnected(tenantId: string | null): boolean { const { data } = useQuery({ queryKey: tenantId === null