@@ -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 ? (
);
@@ -153,8 +156,10 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
const navigate = useNavigate();
const signOut = useMutation(signOutMutationOptions(queryClient));
const channels = useQuery(channelListQueryOptions());
- // One socket for the app, opened where the roster is kept live.
- useChannelEvents();
+ // The socket that keeps this roster live, and carries the notifications behind the markers below,
+ // is opened on the authed boundary. A Bot does not stop needing somebody when they leave this
+ // shell, so it cannot be owned by the shell.
+ const waitingBots = useWaitingBots();
const [search, setSearch] = useState("");
const searching = search.trim().length > 0;
const visibleChannels = matchingChannels(channels.data, search);
@@ -258,6 +263,12 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
key={channel.id}
animateOrder={animateOrder}
channel={channel}
+ // Written for however many coworkers a channel holds rather than for the one it
+ // holds: the marker belongs to the row somebody would click, and any Bot behind it
+ // waiting is reason enough to mark it.
+ waiting={channel.agentIds.some(
+ (agentId) => waitingBots[agentId] !== undefined,
+ )}
/>
))}
diff --git a/app/src/components/app-sidebar/channel.tsx b/app/src/components/app-sidebar/channel.tsx
index 7431478..9422551 100644
--- a/app/src/components/app-sidebar/channel.tsx
+++ b/app/src/components/app-sidebar/channel.tsx
@@ -12,12 +12,21 @@ export const Channel = memo(function Channel({
name,
lastMessage,
lastMessageAt,
+ waiting,
}: {
channelId: string;
participantIds: string[];
name: string;
lastMessage?: string;
lastMessageAt?: string;
+ /**
+ * Whether a Bot in this channel has stopped and is waiting for this person.
+ *
+ * The trace a missed toast leaves. It sits on the row rather than replacing the preview, because
+ * the preview is how somebody recognises which conversation this is, and a row that stops saying
+ * what it is about is harder to find, not easier.
+ */
+ waiting?: boolean;
}) {
return (
{lastMessage}
+ {/* Named for a screen reader, because a coloured dot says nothing to one. */}
+ {waiting ? (
+
+ ) : null}
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 (
+
+ );
+}
+
+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(