diff --git a/app/src/components/agents/agent-profile.tsx b/app/src/components/agents/agent-profile.tsx index e6fcfe1..e4197d3 100644 --- a/app/src/components/agents/agent-profile.tsx +++ b/app/src/components/agents/agent-profile.tsx @@ -11,6 +11,7 @@ import { deleteAgentMutationOptions, duplicateAgentMutationOptions, setAgentHiddenMutationOptions, + setAgentNotificationsMutedMutationOptions, updateAgentMutationOptions, } from "@/lib/agents/mutations"; import { agentQueryOptions } from "@/lib/agents/queries"; @@ -68,6 +69,9 @@ export function AgentProfile({ agentId }: { agentId: string }) { duplicateAgentMutationOptions(queryClient), ); const setHidden = useMutation(setAgentHiddenMutationOptions(queryClient)); + const setNotificationsMuted = useMutation( + setAgentNotificationsMutedMutationOptions(queryClient), + ); const deleteAgent = useMutation(deleteAgentMutationOptions(queryClient)); if (agent.isPending) { @@ -83,7 +87,10 @@ export function AgentProfile({ agentId }: { agentId: string }) { const profile = agent.data; const actionError = - duplicateAgent.error ?? setHidden.error ?? deleteAgent.error; + duplicateAgent.error ?? + setHidden.error ?? + setNotificationsMuted.error ?? + deleteAgent.error; return (
@@ -203,6 +210,34 @@ export function AgentProfile({ agentId }: { agentId: string }) {

) : null} + {/* + * Beside Hide, because it is the same kind of thing: an opinion you hold about this Bot + * that changes nothing for anybody else and nothing about what the Bot may do. + */} + + + {profile.notificationsMuted ? ( +

+ You will not be told when this Bot stops and waits for you. It + still asks, its screen still shows the prompt, and the audit trail + still records the handover. +

+ ) : null} + {profile.canManage ? (
diff --git a/app/src/components/notifications/waiting-toasts.tsx b/app/src/components/notifications/waiting-toasts.tsx new file mode 100644 index 0000000..3ef8026 --- /dev/null +++ b/app/src/components/notifications/waiting-toasts.tsx @@ -0,0 +1,237 @@ +import { IconX } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { agentListQueryOptions } from "@/lib/agents/queries"; +import { channelListQueryOptions } from "@/lib/channels/queries"; +import { + DESKTOP_NOTIFICATIONS_STORAGE_KEY, + parseDesktopNotificationsPreference, + showDesktopNotification, +} from "@/lib/notifications/desktop"; +import { + type BotNotification, + clearBotWaiting, + useWaitingBots, +} from "@/lib/notifications/waiting"; +import { Button } from "../ui/button"; + +/** + * The corner of the screen where a Bot says it has stopped and is waiting for you. + * + * Deliberately the least of the three surfaces this fact has. The audit trail records the handover, + * the Bot's own screen shows the prompt that answers it, and the sidebar keeps a marker until + * somebody opens the Bot. This is the one that catches an eye that is elsewhere, and it is allowed + * to be missed: it goes away on its own, and nothing depends on it having been read. + * + * A click goes to the Bot with its screen open, because the screen is where the prompt that unblocks + * it lives. Sending somebody to a transcript would be sending them to the right conversation and the + * wrong pane. + */ + +/** + * How long a toast stays. + * + * Long enough to notice out of the corner of an eye and act on, short enough that the corner of the + * screen does not accumulate. It can afford to be short because the sidebar marker is the thing that + * persists; nothing is lost when this goes. + * + * Counted by each card for itself rather than by the list that holds them. A timer owned by the list + * has to be torn down whenever the list is rebuilt, and the list is rebuilt every time any Bot's + * marker changes anywhere in the product: a second Bot asking, or the first one's marker being + * cleared by somebody opening it, would cancel the countdown of a card already on screen and leave + * it there for good. That is the opposite of what this surface promises, and because the cards take + * pointer events, a stranded one goes on covering the corner of somebody's work. + */ +const VISIBLE_MS = 12_000; + +const ENTRANCE_SECONDS = 0.2; +const EASE_OUT = [0.23, 1, 0.32, 1] as const; + +export function WaitingToasts() { + const waiting = useWaitingBots(); + const navigate = useNavigate(); + const channels = useQuery(channelListQueryOptions()); + /** + * The roster, for the name on the card. + * + * The channel list is not that. A channel is named after everybody in it, so in a channel with two + * Bots it would put both names in front of somebody and say one of them was waiting. Read from the + * roster the name belongs to instead, and fall back to the id, which is a poor label and an honest + * one. + */ + const agents = useQuery(agentListQueryOptions()); + /** + * Which notifications have already been announced. + * + * Seeded from what was already outstanding when this mounted, and that seeding is the point. The + * marker store survives a reload, so a set that started empty would greet somebody with a toast, + * and a desktop notification, for every Bot that has ever gone unanswered, every time they opened + * the app. Only what arrives while this is mounted is announced. + */ + const [announced] = useState( + () => + new Set(Object.values(waiting).map((notification) => notification.id)), + ); + const [showing, setShowing] = useState([]); + + const roster = agents.data; + /** The Bot's name where the roster knows it. An id is a poor label, and better than none. */ + const nameOf = useCallback( + (botId: string) => + roster?.find((agent) => agent.id === botId)?.name ?? botId, + [roster], + ); + + /* + * Re-running when the roster arrives is free, and refusing to would not be. + * + * `announced` is what stops a notification being told twice, so an extra run finds nothing new and + * returns. Leaving the roster out of the dependencies to avoid those runs is the version that goes + * wrong: the first notification of a session lands before the roster does, and the desktop + * notification would then carry an id where a name should be. + */ + useEffect(() => { + const arrived = Object.values(waiting).filter( + (notification) => !announced.has(notification.id), + ); + if (arrived.length === 0) return; + for (const notification of arrived) { + announced.add(notification.id); + // The operating system's own notification, only where somebody turned it on. See desktop.ts: + // nothing here ever asks for the permission. + if (desktopNotificationsWanted()) { + showDesktopNotification(nameOf(notification.botId), notification); + } + } + setShowing((current) => [...current, ...arrived]); + }, [waiting, announced, nameOf]); + + const dismiss = (id: string) => + setShowing((current) => + current.filter((notification) => notification.id !== id), + ); + + const open = async (notification: BotNotification) => { + dismiss(notification.id); + // Cleared on the way, not on arrival. Opening the Bot is the answer to the question, and waiting + // for the destination to render would leave the marker up if the navigation failed. + clearBotWaiting(notification.botId); + const channel = (channels.data ?? []).find((candidate) => + candidate.agentIds.includes(notification.botId), + ); + if (channel) { + await navigate({ + to: "/channel/$channelId", + params: { channelId: channel.id }, + search: { watch: true }, + }); + return; + } + // A Bot nobody has started a channel with still has somewhere to be opened. + await navigate({ to: "/bot", search: { agent: notification.botId } }); + }; + + return ( +
+ + {showing.map((notification) => ( + dismiss(notification.id)} + onOpen={() => void open(notification)} + /> + ))} + +
+ ); +} + +function desktopNotificationsWanted(): boolean { + try { + return parseDesktopNotificationsPreference( + window.localStorage.getItem(DESKTOP_NOTIFICATIONS_STORAGE_KEY), + ); + } catch { + return false; + } +} + +function Toast({ + botName, + notification, + onDismiss, + onOpen, +}: { + botName: string; + notification: BotNotification; + onDismiss: () => void; + onOpen: () => void; +}) { + const shouldReduceMotion = useReducedMotion(); + /** + * The card's own countdown, started when it appeared and cancelled only when it goes. + * + * Reached through a ref so the effect has nothing to depend on but the mount. `onDismiss` closes + * over the list's state and is a new function on every render of it, and an effect that listed it + * would restart the countdown every time any Bot's marker changed anywhere — the slower version of + * never expiring at all. + */ + const expire = useRef(onDismiss); + useEffect(() => { + expire.current = onDismiss; + }); + useEffect(() => { + const timer = window.setTimeout(() => expire.current(), VISIBLE_MS); + return () => window.clearTimeout(timer); + }, []); + + return ( + +
+ + {/* The whole card opens the Bot; the close button is the only other target inside it. */} + + +
+
+ ); +} diff --git a/app/src/components/settings/desktop-notifications.tsx b/app/src/components/settings/desktop-notifications.tsx new file mode 100644 index 0000000..cced62e --- /dev/null +++ b/app/src/components/settings/desktop-notifications.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { Switch } from "@/components/ui/switch"; +import { + DESKTOP_NOTIFICATIONS_STORAGE_KEY, + type DesktopNotificationsState, + parseDesktopNotificationsPreference, + reconcileDesktopNotifications, + requestDesktopNotifications, +} from "@/lib/notifications/desktop"; + +/** + * The switch that asks the browser for permission, and the only thing in the product that does. + * + * Turning it on is the whole consent. Nothing on load asks, nothing on a first notification asks, + * and a person who never opens this page is never prompted — which is the point, because a prompt + * shown before somebody knows what it is for is usually answered with Block, and Block is close + * enough to permanent that the feature is then gone. + * + * The switch refuses to sit at "on" when the browser has said no. A control that reports a state the + * product cannot deliver is worse than one that is off: somebody would rely on it and miss the Bot + * that was waiting for them. So what was stored is settled against the live permission every time + * this is shown, and again whenever the tab is looked at, because revoking a grant is done in the + * browser's own settings and the way back from there is to this tab. + */ +export function DesktopNotificationsSetting() { + const [state, setState] = useState(() => + reconcileDesktopNotifications(storedPreference()), + ); + + useEffect(() => { + const settle = () => { + const stored = storedPreference(); + const next = reconcileDesktopNotifications(stored); + // Written back, not merely displayed. Everything else in the product reads the stored value + // and would go on reading a yes this browser has withdrawn. + if (stored && !next.enabled) store(false); + setState(next); + }; + + settle(); + window.addEventListener("focus", settle); + return () => window.removeEventListener("focus", settle); + }, []); + + const change = async (wanted: boolean) => { + if (!wanted) { + store(false); + setState({ enabled: false, withdrawn: null }); + return; + } + + const answer = await requestDesktopNotifications(); + if (answer !== "granted") { + store(false); + setState({ enabled: false, withdrawn: answer }); + return; + } + store(true); + setState({ enabled: true, withdrawn: null }); + }; + + return ( + + + Desktop notifications + + Tell me when one of my Bots has stopped and is waiting for me, even + when OpenBot is not the window I am looking at. + {state.withdrawn === "denied" ? ( + + This browser is blocking notifications from OpenBot. Allow them in + its site settings and turn this on again. + + ) : null} + {state.withdrawn === "unsupported" ? ( + + This browser does not support notifications. Bots that are waiting + still show in the sidebar. + + ) : null} + + + + void change(wanted)} + /> + + + ); +} + +/* + * Storage failures are swallowed on both sides. + * + * A browser with storage blocked keeps the preference for this tab and forgets it on reload, which + * is a smaller loss than a settings page that throws while somebody is reading it. + */ +function storedPreference(): boolean { + try { + return parseDesktopNotificationsPreference( + window.localStorage.getItem(DESKTOP_NOTIFICATIONS_STORAGE_KEY), + ); + } catch { + return false; + } +} + +function store(enabled: boolean) { + try { + window.localStorage.setItem( + DESKTOP_NOTIFICATIONS_STORAGE_KEY, + enabled ? "on" : "off", + ); + } catch { + // See above. + } +} diff --git a/app/src/lib/agents/mutations.ts b/app/src/lib/agents/mutations.ts index a8f8eef..ecd64ff 100644 --- a/app/src/lib/agents/mutations.ts +++ b/app/src/lib/agents/mutations.ts @@ -89,6 +89,27 @@ export function setAgentHiddenMutationOptions(queryClient: QueryClient) { }); } +/** + * Silence one Bot's notifications, or let it speak again. + * + * Two endpoints rather than one taking a boolean, matching hide and unhide: a person pressing a + * switch is asking for a state, and a toggle that arrives twice because the network was slow leaves + * them with the opposite of what they asked for. + */ +export function setAgentNotificationsMutedMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { agentId: string; muted: boolean }) => { + await agentRequest( + `/api/agents/${variables.agentId}/notifications/${variables.muted ? "mute" : "unmute"}`, + { method: "POST" }, + ); + }, + onSuccess: () => invalidateAgents(queryClient), + }); +} + export function deleteAgentMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: async (agentId: string) => { diff --git a/app/src/lib/agents/queries.ts b/app/src/lib/agents/queries.ts index 91c5224..f5384b9 100644 --- a/app/src/lib/agents/queries.ts +++ b/app/src/lib/agents/queries.ts @@ -20,6 +20,13 @@ export type AgentProfile = { /** Whether a key is set for it. Never the key itself. */ hasAuth: boolean; hidden: boolean; + /** + * Whether this person has silenced this Bot's notifications. + * + * Per person, like `hidden`. Silencing changes nothing about what the Bot does or what the audit + * trail records; it only stops this person being interrupted by it. + */ + notificationsMuted: boolean; systemOwned: boolean; canManage: boolean; /** diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts index 4d4efe7..504d9b9 100644 --- a/app/src/lib/channels/use-channel-events.ts +++ b/app/src/lib/channels/use-channel-events.ts @@ -1,21 +1,43 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; +import { + type BotNotification, + noteBotWaiting, +} from "@/lib/notifications/waiting"; import { type ChannelSummary, channelKeys } from "./queries"; /** - * Keep the roster live. + * Keep the roster live, and hear about a Bot that has stopped and is waiting. * - * The query remains the source of truth; socket events only patch its cache. Reconnects refetch the - * list to recover events missed while disconnected. + * The query remains the source of truth; socket events only patch its cache, and a reconnect refetches + * the list to recover the activity missed while disconnected. Notifications have no equivalent, and + * the gap is worth stating rather than leaving to be discovered: nothing on the server holds an + * outstanding notification, so one raised while this socket was down is not delivered late. A Bot in + * that position is still waiting on its own screen and the handover is still in the audit trail, but + * the corner of the screen and the sidebar marker will not know about it. + * + * One socket for both, because it is one socket: the server tags what it sends and this dispatches + * on the tag. A second connection would need its own upgrade, its own reconnect and its own backoff + * to carry a payload this one is already open for. An event whose tag this build does not recognise + * is ignored rather than guessed at, so a tab left open across a deploy skips what it cannot read + * instead of corrupting its roster with it. */ type ChannelActivityEvent = { + type: "channel.activity"; channelId: string; lastMessage: string | null; lastMessageAt: string | null; lastMessageAgentId: string | null; }; +type NotificationEvent = { + type: "notification"; + notification: BotNotification; +}; + +type LiveEvent = ChannelActivityEvent | NotificationEvent; + const FIRST_RETRY_MS = 500; const MAX_RETRY_MS = 30_000; @@ -40,18 +62,32 @@ export function useChannelEvents() { socket.onopen = () => { retryDelay = FIRST_RETRY_MS; - // Recover events missed while the socket was disconnected. + // Recover the roster activity missed while the socket was disconnected. Notifications are not + // recovered here; see the header. void queryClient.invalidateQueries({ queryKey: channelKeys.list() }); }; socket.onmessage = (message) => { - let activity: ChannelActivityEvent; + let event: LiveEvent; try { - activity = JSON.parse(message.data as string); + event = JSON.parse(message.data as string); } catch { return; } + switch (event.type) { + case "notification": + noteBotWaiting(event.notification); + return; + case "channel.activity": + break; + default: + // A tag this build has never heard of, from a server that has been deployed since this + // tab was opened. Skipped, not guessed at. + return; + } + const activity = event; + queryClient.setQueryData( channelKeys.list(), (channels: ChannelSummary[] | undefined) => { @@ -70,7 +106,14 @@ export function useChannelEvents() { const previous = channels[index]; if (!previous) return channels; - const patched = { ...previous, ...activity }; + // Named fields rather than a spread of the event. The event carries a tag as well as the + // preview, and a spread would put it in the cached row for no reason. + const patched = { + ...previous, + lastMessage: activity.lastMessage, + lastMessageAt: activity.lastMessageAt, + lastMessageAgentId: activity.lastMessageAgentId, + }; const next = channels.slice(); next[index] = patched; next.sort(byRecency); diff --git a/app/src/lib/notifications/desktop.ts b/app/src/lib/notifications/desktop.ts new file mode 100644 index 0000000..dc82b45 --- /dev/null +++ b/app/src/lib/notifications/desktop.ts @@ -0,0 +1,130 @@ +import type { BotNotification } from "./waiting"; + +/** + * The operating system's own notification, behind an opt-in nobody is asked for. + * + * A browser permission prompt on load is the single most disliked thing a web application does, and + * it is also self-defeating: asked before they know what the product is for, most people press + * Block, and Block is permanent enough that the feature is then gone for good. So nothing here runs + * until somebody turns the switch on, and the switch is the only thing that asks. + * + * The preference is stored in this browser rather than on the person's account, because the thing it + * governs is granted per browser. A server-side "yes" that this browser has never granted would be a + * promise the product cannot keep, and somebody would trust it and miss a Bot waiting on them. On a + * new machine the switch is off and the answer is honest: this browser has not been asked yet. + */ + +export const DESKTOP_NOTIFICATIONS_STORAGE_KEY = + "openbot-desktop-notifications"; + +/** Off unless this browser was explicitly turned on. Anything else reads as off. */ +export function parseDesktopNotificationsPreference( + value: string | null, +): boolean { + return value === "on"; +} + +/** + * What happened when somebody asked for desktop notifications. + * + * `unsupported` is separate from `denied` because they need different sentences. Denied is a + * decision this person made and can revisit in their browser; unsupported is a browser that has + * nothing to revisit, and telling them to check their settings would send them looking for a control + * that is not there. + */ +export type DesktopPermission = "granted" | "denied" | "unsupported"; + +/** The answers that are not "granted", which are the ones a person has to be told about. */ +export type DesktopRefusal = Exclude; + +type PermissionApi = { + permission: NotificationPermission; + requestPermission: () => Promise; +}; + +function permissionApi(): PermissionApi | null { + if (typeof window === "undefined" || !("Notification" in window)) return null; + return { + permission: Notification.permission, + requestPermission: () => Notification.requestPermission(), + }; +} + +/** + * Ask for permission, once, because somebody just asked for this. + * + * Only ever called from the switch. Every other path in the product reads the answer and never asks + * for it, so there is exactly one place a prompt can come from and it is one the person opened. + */ +export async function requestDesktopNotifications( + api: PermissionApi | null = permissionApi(), +): Promise { + if (!api) return "unsupported"; + if (api.permission === "granted") return "granted"; + // A browser that has already been told no does not show the prompt again; asking anyway resolves + // immediately with the old answer, so this needs no special case beyond reporting it honestly. + const answer = await api.requestPermission(); + return answer === "granted" ? "granted" : "denied"; +} + +/** Where the switch may honestly sit. */ +export type DesktopNotificationsState = { + enabled: boolean; + /** + * Why it is off, when somebody had asked for it to be on. + * + * Null when nothing was withdrawn, which covers both "off because nobody turned it on" and "on and + * working". A sentence about a blocked browser in front of somebody who never asked for + * notifications would be an answer to a question they did not put. + */ + withdrawn: DesktopRefusal | null; +}; + +/** + * Settle what was asked for against what the browser will still do. + * + * The stored preference is not the answer on its own. A grant can be taken away long after it was + * given — somebody revokes it in site settings, or clears the profile, or the browser expires it — + * and none of that comes back through the tab that asked for it. Reading only what was stored leaves + * a switch sitting at "on" while `showDesktopNotification` quietly returns at its permission check, + * which is the one failure this control must not have: somebody trusts it, is not told, and misses + * the Bot that was waiting for them. + * + * Takes the stored preference rather than reading it, and takes the permission API rather than + * reaching for it, so the rule can be argued with in a test rather than only in a browser. + */ +export function reconcileDesktopNotifications( + stored: boolean, + api: PermissionApi | null = permissionApi(), +): DesktopNotificationsState { + if (!stored) return { enabled: false, withdrawn: null }; + if (!api) return { enabled: false, withdrawn: "unsupported" }; + if (api.permission === "granted") return { enabled: true, withdrawn: null }; + return { enabled: false, withdrawn: "denied" }; +} + +/** + * Show one, if this browser is allowed to and this person asked for it. + * + * Silently does nothing otherwise. The toast has already appeared and the sidebar marker is already + * set, so this is the third of three ways the same fact is being told; failing loudly about the one + * that is a nicety would be out of proportion. + */ +export function showDesktopNotification( + botName: string, + notification: BotNotification, +): void { + if (typeof window === "undefined" || !("Notification" in window)) return; + if (Notification.permission !== "granted") return; + try { + new Notification(`${botName} ${notification.headline}`, { + body: notification.detail, + // Tagged by Bot, so a Bot that asks twice replaces its own notification rather than stacking + // two of them in the corner of somebody's screen saying the same thing. + tag: `openbot-waiting-${notification.botId}`, + }); + } catch { + // Some browsers refuse construction outside a service worker even with permission granted. The + // toast is the surface that always works; this one is allowed to be absent. + } +} diff --git a/app/src/lib/notifications/waiting.ts b/app/src/lib/notifications/waiting.ts new file mode 100644 index 0000000..6a5144e --- /dev/null +++ b/app/src/lib/notifications/waiting.ts @@ -0,0 +1,197 @@ +import { useSyncExternalStore } from "react"; + +/** + * Which Bots are waiting for this person, kept where a dismissed toast cannot take it with it. + * + * A toast is gone in a few seconds whether or not anybody read it, and the thing it was announcing + * has not gone anywhere: a Bot stopped at a login wall is still stopped. So the toast is the + * announcement and this is the record, and the sidebar marker reads from here. Somebody who was + * making coffee finds the Bot by looking, which is the whole point. + * + * Kept in `localStorage` rather than in React state so a reload does not clear it. Reloading a tab + * is not an answer to a Bot's question, and the marker disappearing when somebody refreshes would + * make the product look like it had forgotten. The cost is that this is per browser: signing in + * somewhere else shows nothing, which is honest, because the socket does not replay either. + * + * Deliberately not on the server. A row per outstanding notification would be a second answer to + * "is this Bot waiting", and there is already an authoritative one: the computer's own control + * state. The cost of that choice is real and is not argued away here. Nothing re-derives this record + * from control state, and nothing replays what the socket missed, so a notification raised while a + * browser was disconnected — across a deploy, say — leaves no trace on any of these three surfaces. + * The Bot is still visibly waiting on its own screen and the handover is still in the audit trail, + * which is what makes that survivable rather than good. + */ + +/** A notification as it arrives from the server. See server/src/notifications.ts. */ +export type BotNotification = { + id: string; + /** + * Left as a plain string on purpose. + * + * The server owns the vocabulary and will grow it. A browser that has not been reloaded since the + * last deploy should still show a notification of a kind it has never heard of, because the server + * has already sent the words to render; narrowing this to a union here would turn a new kind into + * a blank toast on every stale tab. + */ + kind: string; + botId: string; + headline: string; + detail: string; + at: string; +}; + +/** One outstanding notification per Bot, keyed by the Bot, which is what the marker is attached to. */ +export type WaitingBots = Record; + +export const WAITING_STORAGE_KEY = "openbot-bots-waiting"; + +const EMPTY: WaitingBots = {}; + +/** + * Read what was stored, and treat anything unreadable as nothing. + * + * A stored value that cannot be parsed is from an older shape or a hand-edited key, and throwing + * over it would take the whole sidebar down over a decoration. + */ +export function parseWaitingBots(raw: string | null): WaitingBots { + if (!raw) return EMPTY; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return EMPTY; + } + const entries = Object.entries(parsed as Record).filter( + ([, value]) => isNotification(value), + ); + return Object.fromEntries(entries) as WaitingBots; + } catch { + return EMPTY; + } +} + +function isNotification(value: unknown): value is BotNotification { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.botId === "string" && + typeof candidate.headline === "string" && + typeof candidate.detail === "string" + ); +} + +/** + * Record that a Bot is waiting. + * + * One per Bot, so a Bot that asks twice replaces its own entry rather than accumulating. The marker + * answers "is this Bot waiting for me", which has one answer however many times it has asked, and a + * count would invite somebody to clear them one at a time. + * + * Returns the same object when nothing changed, so a repeated event does not re-render the roster. + */ +export function withWaitingBot( + waiting: WaitingBots, + notification: BotNotification, +): WaitingBots { + if (waiting[notification.botId]?.id === notification.id) return waiting; + return { ...waiting, [notification.botId]: notification }; +} + +/** Forget a Bot, which is what opening it means. Returns the same object when there was nothing. */ +export function withoutWaitingBot( + waiting: WaitingBots, + botId: string, +): WaitingBots { + if (!waiting[botId]) return waiting; + const { [botId]: _cleared, ...remaining } = waiting; + return remaining; +} + +/** + * The live copy, held outside React. + * + * A module-level value rather than a context, because the two places that read it are far apart — + * the sidebar and the toasts — and the thing that writes it is a socket handler that belongs to + * neither. `useSyncExternalStore` needs a snapshot with stable identity, which is why this is + * replaced rather than mutated. + */ +let current: WaitingBots = EMPTY; +let loaded = false; +const listeners = new Set<() => void>(); + +function storage(): Storage | null { + try { + return window.localStorage; + } catch { + // Blocked storage is a browser setting, not a failure worth surfacing. The marker then lasts + // until the tab is reloaded, which is still better than nothing. + return null; + } +} + +function snapshot(): WaitingBots { + if (!loaded) { + current = parseWaitingBots(storage()?.getItem(WAITING_STORAGE_KEY) ?? null); + loaded = true; + } + return current; +} + +function commit(next: WaitingBots) { + if (next === current) return; + current = next; + loaded = true; + try { + storage()?.setItem(WAITING_STORAGE_KEY, JSON.stringify(next)); + } catch { + // A quota failure loses persistence, not the marker in front of the person right now. + } + for (const listener of listeners) listener(); +} + +/** Announce that a Bot is waiting. Called by whatever is listening to the socket. */ +export function noteBotWaiting(notification: BotNotification) { + commit(withWaitingBot(snapshot(), notification)); +} + +/** Clear a Bot's marker. Called when somebody opens it, which is the only thing that answers it. */ +export function clearBotWaiting(botId: string) { + commit(withoutWaitingBot(snapshot(), botId)); +} + +/** + * Another tab of this browser, writing the same key. + * + * Without this the two diverge and one of them wins by accident. `current` is a module value, so a + * tab whose copy predates another tab clearing a marker writes that marker back on its next commit, + * and a Bot somebody has already dealt with reappears in their sidebar. The event does not fire in + * the tab that wrote, which is what makes listening to it safe. + * + * Registered once at import rather than by a hook, because the store is read from two places that + * mount and unmount independently and this has to hold whether or not either of them is on screen. + */ +if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + // A null key is the whole of storage being cleared, which is also news. + if (event.key !== null && event.key !== WAITING_STORAGE_KEY) return; + current = parseWaitingBots( + event.newValue ?? storage()?.getItem(WAITING_STORAGE_KEY) ?? null, + ); + loaded = true; + for (const listener of listeners) listener(); + }); +} + +/** Which Bots are waiting, live. */ +export function useWaitingBots(): WaitingBots { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + snapshot, + // The server never renders this, and there is nothing to render before the browser has read its + // own storage. + () => EMPTY, + ); +} diff --git a/app/src/routes/_authed.tsx b/app/src/routes/_authed.tsx index f3742d5..b28f458 100644 --- a/app/src/routes/_authed.tsx +++ b/app/src/routes/_authed.tsx @@ -1,4 +1,6 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import { WaitingToasts } from "@/components/notifications/waiting-toasts"; +import { useChannelEvents } from "@/lib/channels/use-channel-events"; import { currentUserQueryOptions } from "../lib/auth/queries"; import { CopilotProvider } from "../lib/copilot/provider"; @@ -13,9 +15,34 @@ export const Route = createFileRoute("/_authed")({ }, // Mounted INSIDE the authed boundary, not at the root: the runtime endpoint requires a session, so // a provider above the sign-in gate would open a run for a visitor who has not signed in yet. - component: () => ( + component: RouteComponent, +}); + +function RouteComponent() { + /* + * The socket, and the corner it announces into, belong to the whole signed-in application. + * + * Held here rather than in the app shell because a Bot does not stop needing somebody when they + * walk into Settings. `CopilotProvider` is on this boundary too, so a Bot can be mid-run and ask + * for help while its person is reading the preferences page, and a socket that only existed + * alongside the channel rail would mean that ask reached nobody at all: no toast, no marker, no + * desktop notification, and nothing to recover it afterwards. Settings is also where the desktop + * notification switch lives, so the page that offers the feature was the page it could not reach. + * + * One socket for the application, still. Every screen under here shares this one, and it is the + * roster query's live patch as well as the notification carrier. + */ + useChannelEvents(); + + return ( + {/* + * Fixed to the viewport and outside every screen's scroller, because a Bot waiting for you is + * not about whichever screen you are on. It takes pointer events only on the cards themselves, + * so it never covers the thing somebody was in the middle of. + */} + - ), -}); + ); +} diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx index 5af2dfa..440818b 100644 --- a/app/src/routes/_authed/_app/channel/$channelId.tsx +++ b/app/src/routes/_authed/_app/channel/$channelId.tsx @@ -13,6 +13,7 @@ import { DetailPanel } from "@/components/layout/detail-panel"; import { Button } from "@/components/ui/button"; import { type AgentChannel, channelQueryOptions } from "@/lib/channels/queries"; import { onComputerActivity } from "@/lib/copilot/computer-activity"; +import { clearBotWaiting, useWaitingBots } from "@/lib/notifications/waiting"; const chatSearchSchema = z.object({ settings: z.boolean().optional(), @@ -65,6 +66,30 @@ function RouteComponent() { /** Needs-you state is rendered by the screen when the screen is already open. */ const needsYou = useNeedsYou(agentId, !isWatching); + /* + * A Bot cannot be marked as unseen while it is the one on screen. + * + * Not on arrival alone, because a Bot can ask while somebody is already looking at it, and a + * marker that only the act of navigating could clear would then sit on the row they are on. On + * every change instead, which also covers arriving from the sidebar, from a bookmark, or from a + * toast; a marker only the toast could clear would stay up for anybody who did not use one. + * + * Every coworker in the channel, not the one the transcript is routed to. The sidebar marks a row + * when any Bot behind it is waiting, and the API will create a channel with more than one, so + * clearing only the first leaves a marker on a row that has already been opened with no way left + * to answer it. The two ends of the same marker have to agree about what a row means. + * + * The toast still appears for the Bot on screen. The two say different things: the toast says this + * has just happened, and the marker says this is outstanding and nobody has looked yet. + */ + const memberIds = channel.data?.agentIds; + const waitingBots = useWaitingBots(); + useEffect(() => { + for (const memberId of memberIds ?? []) { + if (waitingBots[memberId]) clearBotWaiting(memberId); + } + }, [memberIds, waitingBots]); + // Needs-you prompts auto-open the screen because the actionable prompt is rendered there. useEffect(() => { if (!needsYou) return; diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx index cf2c859..55c547b 100644 --- a/app/src/routes/_authed/settings/index.tsx +++ b/app/src/routes/_authed/settings/index.tsx @@ -4,6 +4,7 @@ import { PageSection, PageShell, } from "@/components/layout/page-shell"; +import { DesktopNotificationsSetting } from "@/components/settings/desktop-notifications"; import { useTheme } from "@/components/theme-provider"; import { Item, @@ -50,6 +51,19 @@ function RouteComponent() { + {/* + * Its own section rather than a third row under General, because unlike the theme this one is + * about this browser and not about the account, and the description has to be able to say so. + */} + + + +

+ This applies to this browser only, because the permission is granted + per browser. Silencing an individual Bot is on that Bot's profile. +

+
+
); } diff --git a/app/tests/notifications.test.ts b/app/tests/notifications.test.ts new file mode 100644 index 0000000..603f948 --- /dev/null +++ b/app/tests/notifications.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { + parseDesktopNotificationsPreference, + reconcileDesktopNotifications, + requestDesktopNotifications, +} from "@/lib/notifications/desktop"; +import { + type BotNotification, + parseWaitingBots, + withoutWaitingBot, + withWaitingBot, +} from "@/lib/notifications/waiting"; + +function notification(overrides: Partial = {}) { + return { + id: "notification-1", + kind: "help_requested", + botId: "risk-analyst", + headline: "needs you at the keyboard", + detail: "This page is asking for a code sent to your phone.", + at: "2026-08-15T10:00:00.000Z", + ...overrides, + } satisfies BotNotification; +} + +describe("the record of which Bots are waiting", () => { + test("keeps one entry per Bot, so asking twice does not accumulate", () => { + const first = withWaitingBot({}, notification()); + const again = withWaitingBot( + first, + notification({ id: "notification-2", detail: "Still waiting." }), + ); + + expect(Object.keys(again)).toEqual(["risk-analyst"]); + expect(again["risk-analyst"]?.detail).toBe("Still waiting."); + }); + + test("returns the same object for an event it has already recorded", () => { + const waiting = withWaitingBot({}, notification()); + + // The roster renders from this. A repeated event that produced a new object would re-render + // every row for a marker that has not changed. + expect(withWaitingBot(waiting, notification())).toBe(waiting); + expect(withoutWaitingBot(waiting, "somebody-else")).toBe(waiting); + }); + + test("forgets a Bot that has been opened, and only that one", () => { + const waiting = withWaitingBot( + withWaitingBot({}, notification()), + notification({ id: "notification-2", botId: "expense-manager" }), + ); + + expect(Object.keys(withoutWaitingBot(waiting, "risk-analyst"))).toEqual([ + "expense-manager", + ]); + }); + + test("survives being written down and read back", () => { + const waiting = withWaitingBot({}, notification()); + + // The marker outliving a reload is the whole reason this is stored rather than held in state: + // refreshing a tab is not an answer to a Bot's question. + expect(parseWaitingBots(JSON.stringify(waiting))).toEqual(waiting); + }); + + test("treats anything it cannot read as nothing waiting", () => { + // A value from an older shape, or one somebody edited by hand. Throwing over it would take the + // sidebar down over a decoration. + expect(parseWaitingBots(null)).toEqual({}); + expect(parseWaitingBots("not json")).toEqual({}); + expect(parseWaitingBots("[]")).toEqual({}); + expect(parseWaitingBots('{"risk-analyst":{"id":"x"}}')).toEqual({}); + }); +}); + +describe("desktop notifications", () => { + test("are off unless this browser was explicitly turned on", () => { + expect(parseDesktopNotificationsPreference("on")).toBe(true); + expect(parseDesktopNotificationsPreference("off")).toBe(false); + expect(parseDesktopNotificationsPreference(null)).toBe(false); + expect(parseDesktopNotificationsPreference("true")).toBe(false); + }); + + test("do not prompt a browser that has already granted permission", async () => { + let asked = 0; + const answer = await requestDesktopNotifications({ + permission: "granted", + requestPermission: async () => { + asked += 1; + return "granted"; + }, + }); + + expect(answer).toBe("granted"); + expect(asked).toBe(0); + }); + + test("report a refusal rather than storing an opt-in the browser will not honour", async () => { + const answer = await requestDesktopNotifications({ + permission: "default", + requestPermission: async () => "denied", + }); + + expect(answer).toBe("denied"); + }); + + test("tell a browser without notifications apart from one that said no", async () => { + // Different sentences: denied is a decision somebody can revisit in their browser, unsupported + // sends them looking for a control that is not there. + expect(await requestDesktopNotifications(null)).toBe("unsupported"); + }); +}); + +describe("the switch settled against what the browser will still do", () => { + const granted = { + permission: "granted", + requestPermission: async () => "granted", + } as const; + const denied = { + permission: "denied", + requestPermission: async () => "denied", + } as const; + + test("sits at on only while the grant it was given still stands", () => { + expect(reconcileDesktopNotifications(true, granted)).toEqual({ + enabled: true, + withdrawn: null, + }); + expect(reconcileDesktopNotifications(false, granted)).toEqual({ + enabled: false, + withdrawn: null, + }); + }); + + test("goes off when the browser has taken the grant back, and says which way", () => { + // The failure this exists for: somebody revokes notifications in site settings long after + // turning the switch on, the stored preference still says "on", and every notification is then + // dropped at the permission check with the switch reporting that they are being told. + expect(reconcileDesktopNotifications(true, denied)).toEqual({ + enabled: false, + withdrawn: "denied", + }); + expect(reconcileDesktopNotifications(true, null)).toEqual({ + enabled: false, + withdrawn: "unsupported", + }); + }); + + test("says nothing about a browser somebody never asked to be notified by", () => { + // A sentence about a blocked browser in front of somebody who has the switch off would be an + // answer to a question they did not put. + expect(reconcileDesktopNotifications(false, denied)).toEqual({ + enabled: false, + withdrawn: null, + }); + expect(reconcileDesktopNotifications(false, null)).toEqual({ + enabled: false, + withdrawn: null, + }); + }); +}); diff --git a/server/drizzle/0002_steep_nighthawk.sql b/server/drizzle/0002_steep_nighthawk.sql new file mode 100644 index 0000000..3141326 --- /dev/null +++ b/server/drizzle/0002_steep_nighthawk.sql @@ -0,0 +1 @@ +ALTER TABLE "agent_preferences" ADD COLUMN "notifications_muted_at" timestamp with time zone; \ No newline at end of file diff --git a/server/drizzle/meta/0002_snapshot.json b/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..b252d78 --- /dev/null +++ b/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,2691 @@ +{ + "id": "63ac61a4-ba0e-435c-a3df-610e1b4cc302", + "prevId": "c2caefc9-77dd-42f2-9d57-0cb3e87225d0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chunks": { + "name": "chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chunks_document_position_idx": { + "name": "chunks_document_position_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chunks_document_idx": { + "name": "chunks_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chunks_document_id_documents_id_fk": { + "name": "chunks_document_id_documents_id_fk", + "tableFrom": "chunks", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_cursors": { + "name": "connector_cursors", + "schema": "", + "columns": { + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_cursors_connector_instance_id_connector_instances_id_fk": { + "name": "connector_cursors_connector_instance_id_connector_instances_id_fk", + "tableFrom": "connector_cursors", + "tableTo": "connector_instances", + "columnsFrom": [ + "connector_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_instances": { + "name": "connector_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "connector_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_instances_credential_id_credentials_id_fk": { + "name": "connector_instances_credential_id_credentials_id_fk", + "tableFrom": "connector_instances", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_acls": { + "name": "document_acls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "acl_effect", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_acls_document_principal_effect_idx": { + "name": "document_acls_document_principal_effect_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effect", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_acls_principal_idx": { + "name": "document_acls_principal_idx", + "columns": [ + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_acls_document_id_documents_id_fk": { + "name": "document_acls_document_id_documents_id_fk", + "tableFrom": "document_acls", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_connector_source_idx": { + "name": "documents_connector_source_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_connector_deleted_idx": { + "name": "documents_connector_deleted_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_connector_instance_id_connector_instances_id_fk": { + "name": "documents_connector_instance_id_connector_instances_id_fk", + "tableFrom": "documents", + "tableTo": "connector_instances", + "columnsFrom": [ + "connector_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sync_runs_connector_started_at_idx": { + "name": "sync_runs_connector_started_at_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_runs_connector_instance_id_connector_instances_id_fk": { + "name": "sync_runs_connector_instance_id_connector_instances_id_fk", + "tableFrom": "sync_runs", + "tableTo": "connector_instances", + "columnsFrom": [ + "connector_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_subscriptions": { + "name": "webhook_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": { + "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk", + "tableFrom": "webhook_subscriptions", + "tableTo": "connector_instances", + "columnsFrom": [ + "connector_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "ask": { + "name": "ask", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifications_muted_at": { + "name": "notifications_muted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.acl_effect": { + "name": "acl_effect", + "schema": "public", + "values": [ + "allow", + "deny" + ] + }, + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.connector_type": { + "name": "connector_type", + "schema": "public", + "values": [ + "google_drive", + "onedrive" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.sync_status": { + "name": "sync_status", + "schema": "public", + "values": [ + "pending", + "running", + "succeeded", + "failed" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 076f20a..1dc916b 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1787171220703, "tag": "0001_gigantic_sumo", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1787191010234, + "tag": "0002_steep_nighthawk", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index 02c9755..819836b 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -46,6 +46,29 @@ export type AgentProfileStore = { ): Promise; duplicate(actor: AgentActor, id: string): Promise; setHidden(actor: AgentActor, id: string, hidden: boolean): Promise; + /** + * Silence, or unsilence, one Bot's notifications for one person. + * + * Beside `setHidden` rather than somewhere of its own because it is the same kind of thing: an + * opinion one person holds about one Bot, kept in the same row, changing nothing for anybody else. + */ + setNotificationsMuted( + actor: AgentActor, + id: string, + muted: boolean, + ): Promise; + /** + * Whether this person has silenced this Bot. + * + * Takes a user id rather than an actor, and does no access check, because the only caller is the + * notification path and it is about to address this person by that id. There is nothing to leak: + * an id that names nobody, or a Bot this person has never heard of, has no row and is not muted. + * + * False when the read fails, which is the deliberate choice. A preference store that is briefly + * unreachable should cost somebody an unwanted notification, not a Bot that waits all afternoon + * because nothing told them. + */ + notificationsMuted(userId: string, id: string): Promise; softDelete(actor: AgentActor, id: string): Promise; }; @@ -80,6 +103,7 @@ const joinedProjection = { ownerUserId: agentProfiles.ownerUserId, packageId: deploymentPackages.id, hiddenAt: agentPreferences.hiddenAt, + notificationsMutedAt: agentPreferences.notificationsMutedAt, deletedAt: agentProfiles.deletedAt, configuration: agents.configuration, }; @@ -123,6 +147,7 @@ function mapProfile( ownerUserId: row.ownerUserId, systemOwned: row.packageId !== null, hidden: row.hiddenAt !== null, + notificationsMuted: row.notificationsMutedAt !== null, deletedAt: row.deletedAt, endpoint: endpointOf(row.configuration), // Whether a key is set, never which. The form needs to show "a key is set" so a person does not @@ -387,6 +412,51 @@ export function createAgentProfileStore( }); }, + setNotificationsMuted(actor, id, muted) { + return database.transaction(async (transaction) => { + const profile = await findAccessibleProfile(transaction, actor, id); + if (!profile) throw new AgentNotFoundError(id); + + const notificationsMutedAt = muted ? new Date() : null; + await transaction + .insert(agentPreferences) + .values({ userId: actor.id, agentId: id, notificationsMutedAt }) + // This column alone. The row is shared with `hiddenAt`, and an upsert that wrote the whole + // row would unhide a Bot somebody had hidden, from a control that says nothing about + // hiding. + .onConflictDoUpdate({ + target: [agentPreferences.userId, agentPreferences.agentId], + set: { notificationsMutedAt }, + }); + }); + }, + + async notificationsMuted(userId, id) { + try { + const [row] = await database + .select({ mutedAt: agentPreferences.notificationsMutedAt }) + .from(agentPreferences) + .where( + and( + eq(agentPreferences.userId, userId), + eq(agentPreferences.agentId, id), + ), + ) + .limit(1); + return row?.mutedAt != null; + } catch (error) { + console.error( + JSON.stringify({ + type: "notification-preference-read-failed", + agent: id, + error: String(error), + note: "Read as not muted, so a Bot waiting on somebody is still announced.", + }), + ); + return false; + } + }, + softDelete(actor, id) { return database.transaction( async (transaction) => { diff --git a/server/src/agents/profile-types.ts b/server/src/agents/profile-types.ts index ad2e2f6..480ffb2 100644 --- a/server/src/agents/profile-types.ts +++ b/server/src/agents/profile-types.ts @@ -15,6 +15,14 @@ export type AgentProfile = { ownerUserId: string | null; systemOwned: boolean; hidden: boolean; + /** + * Whether this person has silenced this Bot's notifications. + * + * Per person, like `hidden`, so it is on the profile the way that is: what the roster shows is + * already whatever the person asking has decided about it, and a second shape for a second + * preference would be two answers to the same question. + */ + notificationsMuted: boolean; deletedAt: Date | null; /** Where this coworker runs. Null for the Bot in the box. */ endpoint: string | null; diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index 303ec8e..010f2a6 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -307,6 +307,43 @@ export function createAgentRoutes( } }); + /** + * Silence one Bot, and let it speak again. + * + * Two routes rather than one taking a boolean, matching hide and unhide above. A person pressing + * a switch is asking for a state, not for a toggle, and a toggle that arrives twice because the + * network was slow leaves them with the opposite of what they asked for. + */ + routes.post("/:agentId/notifications/mute", requireUser, async (context) => { + try { + await store.setNotificationsMuted( + context.var.actor, + context.req.param("agentId"), + true, + ); + return context.body(null, 204); + } catch (error) { + return mapStoreError(context, error); + } + }); + + routes.post( + "/:agentId/notifications/unmute", + requireUser, + async (context) => { + try { + await store.setNotificationsMuted( + context.var.actor, + context.req.param("agentId"), + false, + ); + return context.body(null, 204); + } catch (error) { + return mapStoreError(context, error); + } + }, + ); + routes.delete("/:agentId", requireUser, async (context) => { try { await store.softDelete(context.var.actor, context.req.param("agentId")); @@ -340,6 +377,7 @@ function agentDto(actor: AgentActor, agent: AgentProfile) { avatarSeed: agent.avatarSeed, visibility: agent.visibility, hidden: agent.hidden, + notificationsMuted: agent.notificationsMuted, systemOwned: agent.systemOwned, // Published so the edit form can show it. Safe to expose: it is an address the person supplied, // and any credential for it lives in the vault, never in this row. diff --git a/server/src/channels/events.ts b/server/src/channels/events.ts index 3bd6cd1..15a05e4 100644 --- a/server/src/channels/events.ts +++ b/server/src/channels/events.ts @@ -1,36 +1,81 @@ +import { sql } from "drizzle-orm"; import postgres from "postgres"; +import type { Database } from "../db/client"; +import type { Notification } from "../notifications"; /** - * Live channel activity, from whoever ran an agent to everybody else in the channel. + * Live events, from whichever server process produced them to whichever one the person is connected + * to. * - * The person who ran it already has the reply and reports it over HTTP; this is the other direction, - * telling the channel's other members that something was said. It is an optimisation and never a - * source of truth: the roster query stays authoritative, and a client that misses events while - * disconnected recovers by refetching on reconnect. Nothing may be knowable only through the socket. + * Two things travel this way. Channel activity tells a channel's other members that something was + * said; the person who ran the agent already has the reply and reports it over HTTP, so this is the + * other direction. Notifications tell one person that a Bot of theirs has stopped and is waiting on + * them. + * + * Neither is a source of truth. The roster query stays authoritative for activity, and a Bot that is + * waiting is still visibly waiting on its own screen; a client that misses events while disconnected + * recovers by refetching on reconnect. Nothing may be knowable only through the socket. * * Delivery goes through Postgres rather than an in-process list, because an in-process list is * silently wrong the moment a second server instance exists: the writer is on one and the listener - * on the other, and the message is never delivered. + * on the other, and the message is never delivered. That is not a hypothetical for a notification. + * A deployment runs more than one process, the browser holds its socket to whichever one answered + * the upgrade, and the handover that raises the notification is served by whichever one answered + * that request. Nothing arranges for those to be the same process, so an event raised in memory + * reaches the person only when they happen to have got lucky, which is worse than not having the + * feature: it works on a laptop and stops working in production, silently. + * + * One socket carries both, so the events are tagged. A second WebSocket would need its own upgrade, + * its own session guard, its own reconnect and its own backoff, all to move a payload the existing + * one is already open for. */ export const CHANNEL_ACTIVITY_TOPIC = "channel_activity"; +/** + * Notifications, on a topic of their own rather than folded into channel activity. + * + * Separate because the two are addressed differently. Activity goes to a channel's members, which + * the writer resolved from the membership table; a notification goes to one person, and the rule + * for who that is belongs to notifications.ts. Sharing a topic would mean every listener parsing + * every event to discover it was not for them. + */ +export const NOTIFICATION_TOPIC = "user_notification"; + export type ChannelActivityEvent = { + type: "channel.activity"; channelId: string; /** Who may receive it. Resolved by the writer, which already had to check membership. */ - memberIds: string[]; + recipientIds: string[]; lastMessage: string | null; lastMessageAt: string | null; lastMessageAgentId: string | null; }; +export type NotificationEvent = { + type: "notification"; + /** Whose Bot is waiting. One person, in practice; a list because the hub addresses everything that way. */ + recipientIds: string[]; + notification: Notification; +}; + +/** + * Anything the hub can fan out. + * + * `type` is on the wire rather than inferred from the shape, so a browser that receives an event it + * was not built to understand can ignore it by name instead of by guessing. A deployment can be + * mid-rollout with an older tab open, and an old tab treating a notification as channel activity + * would corrupt its roster rather than skip an event. + */ +export type LiveEvent = ChannelActivityEvent | NotificationEvent; + type Send = (payload: string) => void; export type ChannelEventHub = { /** Attach a connection for a person. Returns the detach. */ register(userId: string, send: Send): () => void; /** Fan one event out to this instance's own connections. */ - deliver(event: ChannelActivityEvent): void; + deliver(event: LiveEvent): void; connectionCount(userId: string): number; }; @@ -54,7 +99,7 @@ export function createChannelEventHub(): ChannelEventHub { }, deliver(event) { - for (const userId of event.memberIds) { + for (const userId of event.recipientIds) { for (const send of connections.get(userId) ?? []) { try { send(JSON.stringify(event)); @@ -72,28 +117,34 @@ export function createChannelEventHub(): ChannelEventHub { }; } -export type ChannelActivityListener = { stop: () => Promise }; +export type LiveEventListener = { stop: () => Promise }; /** - * Listen for activity announced by any instance, including this one. + * Listen for events announced by any instance, including this one. * * On its own connection, because `LISTEN` holds one for the life of the subscription: taken from the - * pool, it would be a connection the rest of the server never gets back. + * pool, it would be a connection the rest of the server never gets back. Both topics share that one + * connection for the same reason, a second subscription is a second connection held forever, and + * nothing about a notification needs its own. */ -export async function startChannelActivityListener( +export async function startLiveEventListener( databaseUrl: string, hub: ChannelEventHub, -): Promise { +): Promise { const connection = postgres(databaseUrl, { max: 1 }); - await connection.listen(CHANNEL_ACTIVITY_TOPIC, (payload) => { + const fanOut = (payload: string) => { try { - hub.deliver(JSON.parse(payload) as ChannelActivityEvent); + hub.deliver(JSON.parse(payload) as LiveEvent); } catch { // A payload we cannot read is not a reason to tear down the subscription: the roster query is - // still correct, and the next refetch shows whatever this event would have. + // still correct, the Bot is still visibly waiting on its own screen, and the next refetch + // shows whatever this event would have. } - }); + }; + + await connection.listen(CHANNEL_ACTIVITY_TOPIC, fanOut); + await connection.listen(NOTIFICATION_TOPIC, fanOut); return { stop: async () => { @@ -101,3 +152,23 @@ export async function startChannelActivityListener( }, }; } + +/** + * Announce a notification to whoever it is for, wherever they are connected. + * + * Takes an executor rather than a pool so a caller inside a transaction can announce on commit, the + * way the channel store does. Nothing raises a notification inside a transaction today, and the + * first thing that does should not have to move this function to do it. + * + * `NOTIFY` caps its payload at 8000 bytes. A notification is a couple of ids, a fixed headline and a + * detail that notifications.ts has already clipped, so the cap is not close; it is worth knowing + * about before somebody adds a field that carries a transcript. + */ +export async function announceNotification( + executor: Pick, + event: NotificationEvent, +): Promise { + await executor.execute( + sql`select pg_notify(${NOTIFICATION_TOPIC}, ${JSON.stringify(event)})`, + ); +} diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index 4789811..e8a2c85 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -323,8 +323,9 @@ export function createChannelStore( // back is never announced. The payload carries the members because the writer has already // resolved them; NOTIFY caps at 8000 bytes, which a 200-character preview leaves room in. const event: ChannelActivityEvent = { + type: "channel.activity", channelId, - memberIds: members.map((member) => member.userId), + recipientIds: members.map((member) => member.userId), lastMessage, lastMessageAt: activity.at.toISOString(), lastMessageAgentId: activity.agentId, diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index bbe3b71..98a7db6 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -18,6 +18,7 @@ * The refs are opaque to the caller precisely so that the server holds the mapping. */ import { type AuditStore, recordAuditEvent } from "../audit"; +import type { NotificationRaiser } from "../notifications"; import { type ApprovalRegistry, createApprovalRegistry, @@ -128,6 +129,17 @@ export type ComputerGatewayOptions = { * minutes is a test somebody eventually deletes. */ repeat?: RepeatDetector; + /** + * Where "this Bot has stopped and is waiting for you" is announced. + * + * Optional, and absent leaves every handover working exactly as it did: the Bot still asks, the + * control state still flips, and the screen still shows the prompt. What is lost is the person + * being told when they are not looking at that screen, which is a courtesy and not a control. + * + * A courtesy is the reason this is raised rather than awaited. Telling somebody must never be + * able to delay, or fail, the handover that lets them unblock the Bot. + */ + notify?: NotificationRaiser; }; /** @@ -145,7 +157,7 @@ type CachedSnapshot = { }; export function createComputerGateway(options: ComputerGatewayOptions) { - const { client, auditStore, supervisor } = options; + const { client, auditStore, supervisor, notify } = options; const snapshots = new Map(); const approvals = options.approvals ?? createApprovalRegistry(); const repeat = options.repeat ?? createRepeatDetector(); @@ -470,6 +482,14 @@ export function createComputerGateway(options: ComputerGatewayOptions) { computerId, reason, }); + // After the row, because the trail is the record and the notification is a message about it. + // A person told about something the trail does not contain would have nothing to look at. + notify?.({ + kind: "help_requested", + botId, + userId: actor.id, + detail: reason, + }); return state; }, @@ -610,6 +630,15 @@ export function createComputerGateway(options: ComputerGatewayOptions) { computerId, reason: `${input.label} (into ${input.ref})`, }); + // The label only. It is what the page asked for, which is the part that tells a person whether + // to get up; the ref means nothing away from the screen and the value is on another path + // entirely, and a notification is read in places an audit row is not. + notify?.({ + kind: "secret_requested", + botId, + userId: actor.id, + detail: input.label, + }); return state; }, diff --git a/server/src/db/schema/coworker.ts b/server/src/db/schema/coworker.ts index 8438f33..1f7fdb9 100644 --- a/server/src/db/schema/coworker.ts +++ b/server/src/db/schema/coworker.ts @@ -49,6 +49,14 @@ export const agentProfiles = pgTable( ], ); +/** + * What one person has decided about one Bot. + * + * Per person and per Bot, because both of the things kept here are opinions rather than facts about + * the Bot: hiding it from a roster changes nothing for anybody else, and neither does silencing it. + * A row exists only once somebody has said something, so the absence of a row is the default answer + * to every column, which is why every column is nullable. + */ export const agentPreferences = pgTable( "agent_preferences", { @@ -59,6 +67,20 @@ export const agentPreferences = pgTable( .notNull() .references(() => agents.id, { onDelete: "cascade" }), hiddenAt: timestamp("hidden_at", { withTimezone: true }), + /** + * When this person silenced this Bot's notifications, or null. + * + * A timestamp rather than a boolean, matching `hiddenAt`, because "when did this stop telling me + * anything" is the question somebody asks after a Bot has been quiet for a week and they cannot + * remember doing it. + * + * Silences the notification only. The Bot still asks for help, its screen still shows the + * prompt, and the audit trail still records the handover, because a preference about being + * interrupted must not be able to turn into a preference about being governed. + */ + notificationsMutedAt: timestamp("notifications_muted_at", { + withTimezone: true, + }), }, (table) => [primaryKey({ columns: [table.userId, table.agentId] })], ); diff --git a/server/src/index.ts b/server/src/index.ts index 695d98f..0284c42 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,8 +8,9 @@ import { DEV_ACTOR, initializeDevActorUser } from "./auth/dev-actor"; import { createRoleRepository } from "./auth/guards"; import type { OpenBotRole } from "./auth/roles"; import { + announceNotification, createChannelEventHub, - startChannelActivityListener, + startLiveEventListener, } from "./channels/events"; import { createChannelStore } from "./channels/routes"; import { createStallGuard } from "./channels/stall-guard"; @@ -39,6 +40,7 @@ import { resolveModelApiKey, } from "./credentials"; import { createDatabase } from "./db/client"; +import { type NotificationRaiser, notificationFor } from "./notifications"; import { createPluginStore } from "./plugins/store"; import { createPackageStatusReader, @@ -142,9 +144,9 @@ const channelEvents = createChannelEventHub(); * and owns only what may be done with it. */ const componentStore = createComponentStore(database); -// Its own connection is held for the life of the process; announced activity from any instance -// arrives here and is fanned out to connected members. -const channelActivityListener = await startChannelActivityListener( +// Its own connection is held for the life of the process; anything announced by any instance +// arrives here and is fanned out to whichever of this instance's connections it is addressed to. +const liveEventListener = await startLiveEventListener( config.databaseUrl, channelEvents, ); @@ -267,6 +269,49 @@ console.info( }), }), ); +/** + * A Bot that has stopped and is waiting for somebody, on its way to that somebody. + * + * Three parts that are deliberately three modules. `notifications.ts` decides whether this is worth + * an interruption and frames it, the profile store answers whether this person has silenced this + * Bot, and `announceNotification` puts it on the wire for whichever process the person is connected + * to. Composing them is this file's job because this file is where the deployment's parts meet, and + * because none of the three should have to know the other two exist. + * + * Nothing awaits this. A handover is what makes a blocked Bot recoverable, and it must not be able + * to fail, or to wait, because the database that holds a preference is slow. The consequence is + * accepted rather than papered over: a notification that cannot be delivered is gone, and the + * person finds out the way they did before, by looking. The audit row is written either way and is + * the record. + */ +const raiseNotification: NotificationRaiser = (blocked) => { + void (async () => { + const muted = await agentProfileStore.notificationsMuted( + blocked.userId, + blocked.botId, + ); + const notification = notificationFor(blocked, { muted }); + if (!notification) return; + await announceNotification(database, { + type: "notification", + recipientIds: [blocked.userId], + notification, + }); + })().catch((error: unknown) => { + // Said out loud. A courtesy that silently stops working is indistinguishable from a product that + // never had it, and the first report will be somebody saying their Bot "just hangs". + console.error( + JSON.stringify({ + type: "notification-not-raised", + bot: blocked.botId, + kind: blocked.kind, + error: error instanceof Error ? error.message : String(error), + note: "The handover itself was unaffected and is in the audit trail.", + }), + ); + }); +}; + /** * One Bot's endpoint must not take down the platform. * @@ -356,6 +401,9 @@ const app = createApp( // Read on every decision rather than captured once, so a rule an administrator adds while the // server is running applies to the very next action instead of after a restart. policy: () => policyStore.get(), + // A Bot asking for help or for a secret is the one thing here worth interrupting somebody + // for. Without this it announces itself only on a screen nobody may be looking at. + notify: raiseNotification, approvals, // Stop, reset and the listing act on containers when there are containers to act on. ...(supervisor ? { supervisor } : {}), @@ -525,11 +573,11 @@ if (config.devNoAuth) { ); } -// The activity listener holds a connection of its own for the life of the process. Released on the +// The live event listener holds a connection of its own for the life of the process. Released on the // way out, so a watch-mode restart does not leave one behind on every reload. for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { - void channelActivityListener.stop().finally(() => process.exit(0)); + void liveEventListener.stop().finally(() => process.exit(0)); }); } diff --git a/server/src/notifications.ts b/server/src/notifications.ts new file mode 100644 index 0000000..1edf1ef --- /dev/null +++ b/server/src/notifications.ts @@ -0,0 +1,196 @@ +/** + * The rule for what is worth interrupting somebody for. + * + * A Bot that is blocked on you is worth interrupting somebody for. A Bot that is merely working is + * not. That is the entire rule, and it is written down in one place so that a new notification has + * to argue with it rather than being added quietly somewhere else. + * + * Kept small on purpose, because the worth of a notification is relative to the others. A deployment + * that announces six things a person cannot act on has also stopped announcing the seventh, which + * they could have: the learned response to a notification from this product becomes to dismiss it + * without reading it. Blocked-on-a-person is the one class where the interruption buys something, + * because nothing further happens at all until it is answered. + * + * Delivery is deliberately somebody else's problem. This module decides WHETHER, and produces the + * frame; where that frame travels and which screens it reaches belongs to channels/events.ts. The + * two are apart because they change for different reasons: this changes when somebody argues a new + * kind of event into the product, and the transport changes when a deployment grows a second server + * process. + */ + +/** One kind of thing that could become a notification, and the product's own words for it. */ +type NotificationRule = { + /** + * Whether nothing further happens until a person acts. + * + * The whole decision, and not a hint. False is a legitimate answer for a kind that is worth + * naming in the vocabulary but not worth a person's attention, which is how a kind earns its way + * in without every kind earning an interruption. + */ + blocking: boolean; + /** + * What happened, said by the product rather than by the model. + * + * Fixed text, because this is the line a person reads while looking at something else, and a + * model that has just failed to log in is not the right author for the sentence that describes + * its own failure. What the model said goes in `detail`, where it is treated as words rather than + * as a claim about what the product wants. + */ + headline: string; +}; + +/** + * Every kind, and whether it earns an interruption. + * + * A table rather than a switch so that a kind being added lands beside the ones already here and + * has to state its `blocking` answer out loud, in the same place a reader can compare it against + * the others. Adding "a scheduled run failed" or "a Bot is asking to be allowed to do something" is + * an entry here and nothing else; the frame, the transport and the surface all follow from it. + */ +const KINDS = { + help_requested: { + blocking: true, + headline: "needs you at the keyboard", + }, + secret_requested: { + blocking: true, + headline: "is asking you for a value it must not be told", + }, +} as const satisfies Record; + +export type NotificationKind = keyof typeof KINDS; + +/** The vocabulary, for anything that has to validate a kind that arrived from outside. */ +export const notificationKinds = Object.keys(KINDS) as NotificationKind[]; + +/** + * How much of the Bot's own words survive into the notification. + * + * Short because the two places this lands are a toast beside somebody's work and the operating + * system's own notification, and both truncate whatever they are given without saying they have. + * Clipping here means the sentence ends with an ellipsis a person can recognise rather than in the + * middle of a word. + */ +export const NOTIFICATION_DETAIL_LIMIT = 140; + +/** What is handed over when a Bot becomes blocked. Facts only; the rule is applied here. */ +export type BlockedBot = { + kind: NotificationKind; + /** + * Which Bot is waiting. + * + * The one field a click cannot be routed without. A notification a person cannot act on from the + * notification itself is a worse interruption than none, because it costs the attention and then + * makes them go looking. + */ + botId: string; + /** + * Who it is for: the person whose session the Bot is acting under. + * + * Deliberately not everybody who can see this Bot. A login wall is one person's problem at one + * moment, and paging a whole deployment for it would teach everybody else to ignore the next one. + */ + userId: string; + /** The Bot's own words about what it needs. Flattened before it is shown; never trusted as markup. */ + detail: string; +}; + +/** A notification, framed and ready for whatever carries it. */ +export type Notification = { + /** Stable for one raising, so a surface can key a toast on it and not show the same one twice. */ + id: string; + kind: NotificationKind; + /** Which Bot is waiting, and therefore where a click goes. */ + botId: string; + headline: string; + detail: string; + /** ISO-8601. */ + at: string; +}; + +/** Whether this kind of event is one a person should be interrupted for. */ +export function isWorthInterrupting(kind: NotificationKind): boolean { + return KINDS[kind].blocking; +} + +/** + * Decide, and frame it if the answer is yes. + * + * Null is the ordinary answer, not an error: a kind that does not block, or a Bot this person has + * muted, produces nothing at all rather than a notification somebody downstream is trusted to throw + * away. One place decides, so a surface that renders whatever it is handed is correct by + * construction. + * + * The Bot's name is not here. The frame carries the id because the id is what routes the click, and + * the roster the browser already holds is where a name comes from; resolving one here would put a + * query on the path of a handover to buy a string the surface has anyway. + */ +export function notificationFor( + blocked: BlockedBot, + /** What this person has said about this Bot. See `agentPreferences`. */ + preference: { muted: boolean }, + now: Date = new Date(), +): Notification | null { + if (preference.muted) return null; + if (!isWorthInterrupting(blocked.kind)) return null; + + return { + id: crypto.randomUUID(), + kind: blocked.kind, + botId: blocked.botId, + headline: KINDS[blocked.kind].headline, + detail: summarize(blocked.detail), + at: now.toISOString(), + }; +} + +/** + * Flatten a model's answer into one line fit for a notification. + * + * A model asked for one sentence will sometimes send back three paragraphs and a fenced code block, + * and a notification is one line by definition. Fences are unwrapped rather than dropped along with + * what is inside them: a Bot that puts the whole of what it needs inside backticks would otherwise + * produce an empty notification, which is the one outcome worse than an untidy one. + * + * Control characters go too. This text is rendered in a toast and handed to the operating system's + * own notification, and a terminal escape somebody's page put in front of the model has no business + * following it into either. + * + * So do the characters that are invisible rather than unprintable: zero-width joiners and spaces, + * the byte order mark, and the bidirectional overrides and isolates. They survive a control-character + * strip and whitespace collapsing untouched, and they are the same threat by another route — a page + * can put a right-to-left override in front of the model, the model repeats it in its reason, and the + * notification then renders a sentence that reads backwards from the one the audit trail recorded. + * Removed rather than replaced with a space, because a character of no width is not a word boundary + * and substituting one would split words that were never apart. + */ +export function summarize( + text: string, + limit = NOTIFICATION_DETAIL_LIMIT, +): string { + const unfenced = text.replaceAll(/```[^\n`]*/g, " ").replaceAll("`", ""); + // biome-ignore lint/suspicious/noControlCharactersInRegex: removing them is the point. + const printable = unfenced.replaceAll(/[\u0000-\u001f\u007f-\u009f]+/g, " "); + const visible = printable.replaceAll( + /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g, + "", + ); + const collapsed = visible.replaceAll(/\s+/g, " ").trim(); + + // Counted in code points rather than UTF-16 units, so a clip never lands between the halves of a + // surrogate pair and leaves a replacement character in front of somebody. + const codePoints = Array.from(collapsed); + if (codePoints.length <= limit) return collapsed; + return `${codePoints.slice(0, Math.max(limit - 1, 0)).join("")}…`; +} + +/** + * Raise one, and carry on. + * + * Returns nothing and is never awaited by its caller. A handover is the escape hatch that makes a + * blocked Bot recoverable at all, and it must not fail, or wait, because the thing that tells + * somebody about it is slow or broken. The cost is that a notification which cannot be delivered is + * lost rather than retried, which is the right trade for a courtesy: the audit row is the record, + * and the Bot is still visibly waiting on its own screen. + */ +export type NotificationRaiser = (blocked: BlockedBot) => void; diff --git a/server/tests/agent-profile-store.integration.test.ts b/server/tests/agent-profile-store.integration.test.ts index a3d0691..ddefabe 100644 --- a/server/tests/agent-profile-store.integration.test.ts +++ b/server/tests/agent-profile-store.integration.test.ts @@ -264,6 +264,67 @@ describe("agent profile store integration", () => { expect(preference?.hiddenAt).toBeNull(); }); + test("stores silencing per user, and the notification path reads back what the switch wrote", async () => { + const owner = await createUser(); + const other = await createUser(); + const source = await createProfileFixture({ owner, visibility: "public" }); + + expect(await store.notificationsMuted(owner.id, source.agentId)).toBe( + false, + ); + expect((await profileById(owner, source.agentId)).notificationsMuted).toBe( + false, + ); + + await store.setNotificationsMuted(owner, source.agentId, true); + + // The seam this covers is the whole feature for a person who has silenced a Bot: the switch + // writes a row, and the thing that decides whether to interrupt them reads it. Nothing else in + // the suite joins those two ends, so a rename on either side would go unnoticed until somebody + // was paged by a Bot they had turned off. + expect(await store.notificationsMuted(owner.id, source.agentId)).toBe(true); + expect((await profileById(owner, source.agentId)).notificationsMuted).toBe( + true, + ); + // An opinion one person holds, like hiding. Everybody else still hears from this Bot. + expect(await store.notificationsMuted(other.id, source.agentId)).toBe( + false, + ); + + await store.setNotificationsMuted(owner, source.agentId, false); + expect(await store.notificationsMuted(owner.id, source.agentId)).toBe( + false, + ); + }); + + test("silencing and hiding share a row without overwriting each other", async () => { + const owner = await createUser(); + const source = await createProfileFixture({ owner, visibility: "public" }); + + await store.setNotificationsMuted(owner, source.agentId, true); + // Two preferences in one row, so an upsert that wrote the whole row from either control would + // silently undo the other. A person who hides a Bot has said nothing about being interrupted by + // it, and the reverse. + await store.setHidden(owner, source.agentId, true); + + expect(await store.notificationsMuted(owner.id, source.agentId)).toBe(true); + expectListed(await store.list(owner), source.agentId, false); + + await store.setNotificationsMuted(owner, source.agentId, false); + expectListed(await store.list(owner, true), source.agentId, true); + }); + + test("answers for a Bot nobody has an opinion about, and for one that does not exist", async () => { + const owner = await createUser(); + + // The notification path calls this with whatever the gateway handed it, before anything has + // checked that the pair means something. Not muted is the answer that costs somebody an + // unwanted notification rather than a Bot that waits all afternoon. + expect(await store.notificationsMuted(owner.id, id("absent-agent"))).toBe( + false, + ); + }); + test("takes the endpoint and ignores every field a caller must not set", async () => { const owner = await createUser(); const deploymentPackage = await createPackage(); diff --git a/server/tests/agent-routes.test.ts b/server/tests/agent-routes.test.ts index b05774e..18a3810 100644 --- a/server/tests/agent-routes.test.ts +++ b/server/tests/agent-routes.test.ts @@ -42,6 +42,7 @@ function profile(overrides: Partial = {}): AgentProfile { ownerUserId: actor.id, systemOwned: false, hidden: false, + notificationsMuted: false, deletedAt: null, ...overrides, }; @@ -81,6 +82,13 @@ function fakeStore( async setHidden(receivedActor, id, hidden) { calls.push(["setHidden", receivedActor, id, hidden]); }, + async setNotificationsMuted(receivedActor, id, muted) { + calls.push(["setNotificationsMuted", receivedActor, id, muted]); + }, + async notificationsMuted(userId, id) { + calls.push(["notificationsMuted", userId, id]); + return false; + }, async softDelete(receivedActor, id) { calls.push(["softDelete", receivedActor, id]); }, @@ -226,6 +234,8 @@ describe("agent lifecycle routes", () => { ["/agent-1/duplicate", { method: "POST" }], ["/agent-1/hide", { method: "POST" }], ["/agent-1/unhide", { method: "POST" }], + ["/agent-1/notifications/mute", { method: "POST" }], + ["/agent-1/notifications/unmute", { method: "POST" }], ["/agent-1", { method: "DELETE" }], ]; @@ -289,6 +299,14 @@ describe("agent lifecycle routes", () => { const unhidden = await app.request("http://openbot.test/agent-1/unhide", { method: "POST", }); + const muted = await app.request( + "http://openbot.test/agent-1/notifications/mute", + { method: "POST" }, + ); + const unmuted = await app.request( + "http://openbot.test/agent-1/notifications/unmute", + { method: "POST" }, + ); const deleted = await app.request("http://openbot.test/agent-1", { method: "DELETE", }); @@ -300,6 +318,10 @@ describe("agent lifecycle routes", () => { expect(duplicated.status).toBe(201); expect(hidden.status).toBe(204); expect(unhidden.status).toBe(204); + // Two routes rather than one taking a boolean, so a request that arrives twice because the + // network was slow leaves somebody with the state they asked for rather than its opposite. + expect(muted.status).toBe(204); + expect(unmuted.status).toBe(204); expect(deleted.status).toBe(204); expect(store.calls).toEqual([ ["list", actor, false], @@ -309,6 +331,8 @@ describe("agent lifecycle routes", () => { ["duplicate", actor, "agent-1"], ["setHidden", actor, "agent-1", true], ["setHidden", actor, "agent-1", false], + ["setNotificationsMuted", actor, "agent-1", true], + ["setNotificationsMuted", actor, "agent-1", false], ["softDelete", actor, "agent-1"], ]); }); @@ -318,7 +342,13 @@ describe("agent lifecycle routes", () => { async list() { return [ profile(), - profile({ id: "agent-2", ownerUserId: "user-2" }), + // Silenced, so the projection is shown carrying the preference rather than defaulting it. + // Every row reading false would pass just as well against a field that was never read. + profile({ + id: "agent-2", + ownerUserId: "user-2", + notificationsMuted: true, + }), profile({ id: "system-agent", ownerUserId: null, @@ -341,6 +371,7 @@ describe("agent lifecycle routes", () => { avatarSeed: "expense-manager", visibility: "private", hidden: false, + notificationsMuted: false, systemOwned: false, canManage: true, mine: true, @@ -353,6 +384,7 @@ describe("agent lifecycle routes", () => { avatarSeed: "expense-manager", visibility: "private", hidden: false, + notificationsMuted: true, systemOwned: false, canManage: false, mine: false, @@ -365,6 +397,7 @@ describe("agent lifecycle routes", () => { avatarSeed: "expense-manager", visibility: "public", hidden: false, + notificationsMuted: false, systemOwned: true, canManage: false, mine: false, diff --git a/server/tests/channel-events.integration.test.ts b/server/tests/channel-events.integration.test.ts index 98d9325..cc4f290 100644 --- a/server/tests/channel-events.integration.test.ts +++ b/server/tests/channel-events.integration.test.ts @@ -4,9 +4,11 @@ import { eq } from "drizzle-orm"; import { createAgentProfileStore } from "../src/agents/profile-store"; import type { AgentActor } from "../src/agents/profile-types"; import { + announceNotification, type ChannelActivityEvent, createChannelEventHub, - startChannelActivityListener, + type NotificationEvent, + startLiveEventListener, } from "../src/channels/events"; import { createChannelStore } from "../src/channels/routes"; import { createThreadIdentity } from "../src/channels/thread-identity"; @@ -22,8 +24,9 @@ import { function event(overrides: Partial = {}) { return { + type: "channel.activity", channelId: "channel_1", - memberIds: ["user-1"], + recipientIds: ["user-1"], lastMessage: "Said something.", lastMessageAt: "2026-08-15T10:00:00.000Z", lastMessageAgentId: null, @@ -31,6 +34,22 @@ function event(overrides: Partial = {}) { } satisfies ChannelActivityEvent; } +function notification(overrides: Partial = {}) { + return { + type: "notification", + recipientIds: ["user-1"], + notification: { + id: "notification-1", + kind: "help_requested", + botId: "risk-analyst", + headline: "needs you at the keyboard", + detail: "This page is asking for a code sent to your phone.", + at: "2026-08-15T10:00:00.000Z", + }, + ...overrides, + } satisfies NotificationEvent; +} + describe("channel event hub", () => { test("delivers only to the members of the channel", () => { const hub = createChannelEventHub(); @@ -39,7 +58,7 @@ describe("channel event hub", () => { hub.register("user-1", (payload) => member.push(payload)); hub.register("user-2", (payload) => stranger.push(payload)); - hub.deliver(event({ memberIds: ["user-1"] })); + hub.deliver(event({ recipientIds: ["user-1"] })); expect(member).toHaveLength(1); expect(JSON.parse(member[0] as string).lastMessage).toBe("Said something."); @@ -84,6 +103,54 @@ describe("channel event hub", () => { expect(() => hub.deliver(event())).not.toThrow(); expect(healthy).toHaveLength(1); }); + + /** + * A notification is addressed to one person, not to a channel's membership, and it rides the same + * socket. Both facts are load-bearing: the first is the whole rule about who gets interrupted, and + * the second is why the browser has to be able to tell one kind of event from the other. + */ + test("delivers a notification only to the person it names", () => { + const hub = createChannelEventHub(); + const waiting: string[] = []; + const somebodyElse: string[] = []; + hub.register("user-1", (payload) => waiting.push(payload)); + hub.register("user-2", (payload) => somebodyElse.push(payload)); + + hub.deliver(notification({ recipientIds: ["user-1"] })); + + expect(waiting).toHaveLength(1); + expect(somebodyElse).toEqual([]); + }); + + test("says which kind of event it is, on the wire", () => { + const hub = createChannelEventHub(); + const received: string[] = []; + hub.register("user-1", (payload) => received.push(payload)); + + hub.deliver(event()); + hub.deliver(notification()); + + expect(received.map((payload) => JSON.parse(payload).type)).toEqual([ + "channel.activity", + "notification", + ]); + }); + + test("a notification carries the Bot a click has to land on", () => { + const hub = createChannelEventHub(); + const received: string[] = []; + hub.register("user-1", (payload) => received.push(payload)); + + hub.deliver(notification()); + + const delivered = JSON.parse(received[0] as string) as NotificationEvent; + // Without this the interruption costs the attention and then makes the person go looking, which + // is worse than not having interrupted them. + expect(delivered.notification.botId).toBe("risk-analyst"); + expect(delivered.notification.detail).toBe( + "This page is asking for a code sent to your phone.", + ); + }); }); const databaseUrl = @@ -131,7 +198,7 @@ afterAll(async () => { * trip a second instance would take: a write announces, and a listener that shares nothing with the * writer but the database hears it. */ -describe("channel activity delivery", () => { +describe("live event delivery", () => { test("announces a recorded message to a listener on its own connection", async () => { const id = `${testPrefix}-user-${randomUUID()}`; await database.insert(users).values({ @@ -160,7 +227,7 @@ describe("channel activity delivery", () => { resolve(); }); }); - const listener = await startChannelActivityListener(databaseUrl, hub); + const listener = await startLiveEventListener(databaseUrl, hub); try { await store.recordActivity(owner, channel.id, { @@ -180,10 +247,55 @@ describe("channel activity delivery", () => { expect(delivered).toHaveLength(1); expect(delivered[0]).toMatchObject({ + type: "channel.activity", channelId: channel.id, lastMessage: "Categorized three expenses.", lastMessageAgentId: profile.id, - memberIds: [owner.id], + recipientIds: [owner.id], + }); + }); + + /** + * The same round trip for a notification, which is the one that had to work across processes to be + * worth building: the person is connected to whichever instance answered their upgrade, and the + * handover that raises the notification is served by whichever instance answered that request. + * Nothing arranges for those to be the same one. + */ + test("announces a notification to a listener on its own connection", async () => { + const id = `${testPrefix}-user-${randomUUID()}`; + await database.insert(users).values({ + id, + email: `${id}@example.test`, + name: "Notification Test User", }); + createdUserIds.push(id); + + const hub = createChannelEventHub(); + const delivered: NotificationEvent[] = []; + const arrived = new Promise((resolve) => { + hub.register(id, (payload) => { + delivered.push(JSON.parse(payload)); + resolve(); + }); + }); + const listener = await startLiveEventListener(databaseUrl, hub); + + try { + await announceNotification( + database, + notification({ recipientIds: [id] }), + ); + await Promise.race([ + arrived, + new Promise((_, reject) => + setTimeout(() => reject(new Error("no event within 5s")), 5000), + ), + ]); + } finally { + await listener.stop(); + } + + expect(delivered).toHaveLength(1); + expect(delivered[0]?.notification.botId).toBe("risk-analyst"); }); }); diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index 6de1937..8269bd1 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -13,6 +13,7 @@ import { type RepeatDetector, } from "../src/computer/repeat"; import type { SnapshotResult } from "../src/computer/schema"; +import type { BlockedBot } from "../src/notifications"; /** * What the gateway must guarantee, tested as properties rather than as call sequences. @@ -422,6 +423,81 @@ describe("the computer gateway", () => { }); }); +/** A computer that can be asked for a handover, which the acting fake above has no need to be. */ +function fakeHandoverClient() { + const state = { holder: "bot" as const, since: "", requested: true }; + const client = { + requestControl: async () => state, + requestSecret: async () => state, + forBot: () => client, + } as unknown as ComputerClient; + return client; +} + +function handoverGateway() { + const { store, rows } = fakeAudit(); + const raised: BlockedBot[] = []; + const gateway = createComputerGateway({ + client: fakeHandoverClient(), + auditStore: store, + policy: () => PERMISSIVE, + notify: (blocked) => void raised.push(blocked), + }); + return { gateway, rows, raised }; +} + +/** + * A Bot that has stopped and is waiting is the one thing here worth interrupting somebody for, and + * until it is raised nothing outside that Bot's own screen knows it happened. + */ +describe("a Bot blocked on a person", () => { + test("raises a notification where it writes the audit row for the handover", async () => { + const { gateway, rows, raised } = handoverGateway(); + + await gateway.requestHelp("default", "bot-1", ACTOR, "Sign me in, please."); + + expect(rows.map((row) => row.eventType)).toEqual([ + "computer.help_requested", + ]); + expect(raised).toEqual([ + { + kind: "help_requested", + botId: "bot-1", + userId: ACTOR.id, + detail: "Sign me in, please.", + }, + ]); + }); + + test("names the value a secret request is for, and never a ref or a value", async () => { + const { gateway, raised } = handoverGateway(); + + await gateway.requestSecret("default", "bot-1", ACTOR, { + label: "the code sent to your phone", + ref: "e12", + snapshotId: 7, + }); + + // The label is the part that tells somebody whether to get up. A ref means nothing away from the + // screen, and the value is on a path this one is not on. + expect(raised[0]?.detail).toBe("the code sent to your phone"); + }); + + test("hands over even when nothing is listening for notifications", async () => { + const { store, rows } = fakeAudit(); + const gateway = createComputerGateway({ + client: fakeHandoverClient(), + auditStore: store, + policy: () => PERMISSIVE, + }); + + // A deployment with no notification wiring is a deployment that tells nobody, not one where a + // blocked Bot cannot be rescued. + await gateway.requestHelp("default", "bot-1", ACTOR, "Sign me in, please."); + expect(rows).toHaveLength(1); + }); +}); + /** * The path where the boundary stops and asks. * diff --git a/server/tests/notifications.test.ts b/server/tests/notifications.test.ts new file mode 100644 index 0000000..cd22e0f --- /dev/null +++ b/server/tests/notifications.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test"; +import { + isWorthInterrupting, + NOTIFICATION_DETAIL_LIMIT, + notificationFor, + notificationKinds, + summarize, +} from "../src/notifications"; + +/** + * What the rule must guarantee, and none of it is visible from a green typecheck. + * + * The four that matter: + * - a Bot blocked on a person earns an interruption + * - a person who has silenced a Bot is not interrupted by it, whatever it is doing + * - what the model said is flattened to one line before it is put in front of anybody + * - the frame carries the Bot, so a click on it lands somewhere useful + */ + +const BLOCKED = { + kind: "help_requested", + botId: "risk-analyst", + userId: "user-1", + detail: "This page is asking for a code sent to your phone.", +} as const; + +const HEARD = { muted: false }; +const SILENCED = { muted: true }; + +describe("what is worth interrupting somebody for", () => { + test("a Bot waiting on a person is", () => { + expect(isWorthInterrupting("help_requested")).toBe(true); + expect(isWorthInterrupting("secret_requested")).toBe(true); + }); + + test("every kind in the vocabulary is framed the way the rule says it should be", () => { + expect(notificationKinds.length).toBeGreaterThan(0); + for (const kind of notificationKinds) { + const raised = notificationFor({ ...BLOCKED, kind }, HEARD); + + // The rule and the frame are two functions reading one table, and they must not be able to + // disagree: a kind that says it blocks and then produces nothing is a Bot that waits in + // silence, and one that says it does not and produces a card is the interruption this module + // exists to refuse. + expect(raised !== null).toBe(isWorthInterrupting(kind)); + // A kind added with a `blocking` answer and no words is a card that says the Bot's name and + // then stops, which is worse than not showing it at all. + if (raised) expect(raised.headline.trim()).not.toBe(""); + } + }); + + test("a Bot this person has silenced is not, whatever it is asking for", () => { + expect(notificationFor(BLOCKED, SILENCED)).toBeNull(); + expect( + notificationFor({ ...BLOCKED, kind: "secret_requested" }, SILENCED), + ).toBeNull(); + }); + + test("the preference is read here rather than trusted to a surface downstream", () => { + // One place decides, so anything that renders whatever it is handed is correct by construction. + // A rule that returned a notification marked "do not show this" would put the decision in every + // surface instead, and the first one to forget it would page somebody who had opted out. + expect(notificationFor(BLOCKED, HEARD)).not.toBeNull(); + expect(notificationFor(BLOCKED, SILENCED)).toBeNull(); + }); +}); + +describe("the frame a notification carries", () => { + test("names the Bot, so a click can be routed to the one that is waiting", () => { + const raised = notificationFor(BLOCKED, HEARD); + + expect(raised?.botId).toBe("risk-analyst"); + expect(raised?.kind).toBe("help_requested"); + }); + + test("says what happened in the product's words and what the Bot said in its own", () => { + const raised = notificationFor(BLOCKED, HEARD); + + // The headline is fixed text, so a model that has just failed to log in is not the author of the + // sentence describing its own failure. + expect(raised?.headline).toBe("needs you at the keyboard"); + expect(raised?.detail).toBe( + "This page is asking for a code sent to your phone.", + ); + }); + + test("is stamped with the moment it was raised, and is individually identifiable", () => { + const at = new Date("2026-08-15T10:00:00.000Z"); + const first = notificationFor(BLOCKED, HEARD, at); + const second = notificationFor(BLOCKED, HEARD, at); + + expect(first?.at).toBe("2026-08-15T10:00:00.000Z"); + // Two raisings of the same thing are two notifications. A surface keys a toast on the id, and + // sharing one would silently swallow the second time a Bot asked for help. + expect(first?.id).not.toBe(second?.id); + }); +}); + +describe("summarize", () => { + test("flattens a model's paragraphs into one line", () => { + expect(summarize("The login page\nwants a code.\n\nPlease help.")).toBe( + "The login page wants a code. Please help.", + ); + }); + + test("unwraps a fenced block rather than dropping what is inside it", () => { + // A Bot that puts the whole of what it needs inside backticks would otherwise produce an empty + // notification, which is the one outcome worse than an untidy one. + expect(summarize("Run this:\n```bash\nnpm login\n```\nthen tell me.")).toBe( + "Run this: npm login then tell me.", + ); + expect(summarize("```\nsign in here\n```")).toBe("sign in here"); + }); + + test("removes control characters a page could have put in front of the model", () => { + expect(summarize("Sign in now\u001b[31m.")).toBe("Sign in now [31m."); + }); + + test("removes the invisible characters that survive a control-character strip", () => { + // A right-to-left override reaches the model from whatever page the Bot is on, and would + // otherwise render a notification that reads backwards from the audit row describing the same + // handover. Zero-width characters go for the same reason: invisible to the person reading the + // sentence, and perfectly visible to whatever reads it after them. + expect(summarize("Sign in \u202eyalpsid\u202c now")).toBe( + "Sign in yalpsid now", + ); + expect(summarize("pass\u200bword\ufeff wanted")).toBe("password wanted"); + expect(summarize("\u200b\u2066\u2069")).toBe(""); + }); + + test("clips with an ellipsis at the limit, and leaves shorter text alone", () => { + const long = "a".repeat(NOTIFICATION_DETAIL_LIMIT + 40); + const clipped = summarize(long); + + expect(Array.from(clipped)).toHaveLength(NOTIFICATION_DETAIL_LIMIT); + expect(clipped.endsWith("…")).toBe(true); + + const exact = "b".repeat(NOTIFICATION_DETAIL_LIMIT); + expect(summarize(exact)).toBe(exact); + }); + + test("counts what it clips in code points, not UTF-16 units", () => { + // Sliced by string index, a clip lands between the halves of a surrogate pair and leaves a + // replacement character in front of somebody. + const clipped = summarize("🙂".repeat(20), 10); + + expect(Array.from(clipped)).toHaveLength(10); + expect(clipped.includes("�")).toBe(false); + }); + + test("survives a Bot that said nothing useful", () => { + expect(summarize(" \n\n ")).toBe(""); + expect(summarize("```")).toBe(""); + }); +}); diff --git a/server/tests/schema.test.ts b/server/tests/schema.test.ts index b95cd00..7b067c2 100644 --- a/server/tests/schema.test.ts +++ b/server/tests/schema.test.ts @@ -193,6 +193,16 @@ describe("OpenBot database schema", () => { hasDefault: false, primary: false, }, + // Nullable and without a default, like `hidden_at` beside it. A person who has never said + // anything about a Bot has no row at all, so the absence of one has to be the answer to every + // question this table can be asked. + { + name: "notifications_muted_at", + sqlType: "timestamp with time zone", + notNull: false, + hasDefault: false, + primary: false, + }, ]); expect(