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
88 changes: 88 additions & 0 deletions apps/web/src/deployment-capabilities-api.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response>,
): 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 when checking Slack.",
}),
).toBe(true);
});
});
70 changes: 55 additions & 15 deletions apps/web/src/deployment-capabilities-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeploymentCapabilities> {
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<DeploymentCapabilitiesResult> {
try {
const response = await fetch("/api/deployment-capabilities", {
headers: { accept: "application/json" },
});
if (!response.ok) {
return {
kind: "unavailable",
message: `The server answered ${response.status} when checking Slack.`,
};
}
const body: unknown = await response.json();
const parsed = DeploymentCapabilitiesBody(body);
if (parsed instanceof type.errors) {
return {
kind: "unavailable",
message: `Unexpected Slack 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;
}
14 changes: 10 additions & 4 deletions apps/web/src/shell/routine-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -567,7 +573,7 @@ function RoutineEditorPanel({
<span className="text-xs font-medium">When to run</span>
{trigger === null && !addingSchedule ? (
<AddTriggerMenu
slackAvailable={slackConfigured}
slackAvailable={slackAvailable}
granolaAvailable={granolaConnected}
disabled={busy}
onSchedule={() => setAddingSchedule(true)}
Expand Down
19 changes: 19 additions & 0 deletions apps/web/test/routine-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ let createWorkbenchCalls: Record<string, unknown>[] = [];
let runNowCalls = 0;
let slackConfigured = false;
let granolaConnected = false;
let capabilitiesProbeFails = false;
let networkDelayMs = 0;
let workbenchAgentsByWorkbench: Record<
string,
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -272,6 +279,7 @@ describe("RoutinePanel", () => {
runNowCalls = 0;
slackConfigured = false;
granolaConnected = false;
capabilitiesProbeFails = false;
networkDelayMs = 0;
chatWorkbenches = [];
runsByRoutineId = {};
Expand Down Expand Up @@ -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")));
Expand Down
Loading