Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/web/src/granola-plugin-availability.test.ts
Original file line number Diff line number Diff line change
@@ -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 unknown 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 unknown 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 unknown 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 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 unknown as typeof fetch;
expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false);
});
});
51 changes: 51 additions & 0 deletions apps/web/src/granola-plugin-availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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<boolean> {
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;
}
19 changes: 13 additions & 6 deletions apps/web/src/shell/routine-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -121,7 +124,9 @@ function AddTriggerMenu({
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={onSchedule}>On a schedule ›</MenuItem>
<MenuItem onSelect={onGranola}>Granola call notes</MenuItem>
{granolaAvailable ? (
<MenuItem onSelect={onGranola}>Granola call notes</MenuItem>
) : null}
{slackAvailable ? <MenuItem onSelect={onSlack}>Slack</MenuItem> : null}
</MenuContent>
</Menu>
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -562,6 +568,7 @@ function RoutineEditorPanel({
{trigger === null && !addingSchedule ? (
<AddTriggerMenu
slackAvailable={slackConfigured}
granolaAvailable={granolaConnected}
disabled={busy}
onSchedule={() => setAddingSchedule(true)}
onGranola={() => addWebhookTrigger("Granola call notes")}
Expand Down
44 changes: 44 additions & 0 deletions apps/web/test/routine-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ let createRoutineCalls: Record<string, unknown>[] = [];
let createWorkbenchCalls: Record<string, unknown>[] = [];
let runNowCalls = 0;
let slackConfigured = false;
let granolaConnected = false;
let networkDelayMs = 0;
let workbenchAgentsByWorkbench: Record<
string,
Expand Down Expand Up @@ -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" }],
Expand Down Expand Up @@ -256,6 +271,7 @@ describe("RoutinePanel", () => {
createWorkbenchCalls = [];
runNowCalls = 0;
slackConfigured = false;
granolaConnected = false;
networkDelayMs = 0;
chatWorkbenches = [];
runsByRoutineId = {};
Expand Down Expand Up @@ -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();
Expand All @@ -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")));
Expand Down
Loading