diff --git a/app/package.json b/app/package.json index f4418a7f..cce1f380 100644 --- a/app/package.json +++ b/app/package.json @@ -21,6 +21,7 @@ "@shadcn/react": "^0.3.0", "@tabler/icons-react": "^3.36.1", "@tanstack/react-form": "^1.33.5", + "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.27", "better-auth": "^1.6.27", @@ -31,6 +32,7 @@ "prompt-area": "^0.6.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-use-measure": "^2.1.7", "shadcn": "^4.17.0", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index 49052645..94051101 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -199,6 +199,7 @@ function ChannelRow({ } pinned={channel.pinned} unread={unread} + busy={channel.busy ?? false} /> ); diff --git a/app/src/components/app-sidebar/channel.tsx b/app/src/components/app-sidebar/channel.tsx index 18733576..194962d3 100644 --- a/app/src/components/app-sidebar/channel.tsx +++ b/app/src/components/app-sidebar/channel.tsx @@ -43,6 +43,7 @@ export const Channel = memo(function Channel({ lastMessageAt, pinned, unread, + busy, }: { channelId: string; participantIds: string[]; @@ -51,6 +52,7 @@ export const Channel = memo(function Channel({ lastMessageAt?: string; pinned: boolean; unread: boolean; + busy: boolean; }) { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -110,7 +112,11 @@ export const Channel = memo(function Channel({ }} >
- +
diff --git a/app/src/components/channels/avatar.tsx b/app/src/components/channels/avatar.tsx index ae5de378..11a19e22 100644 --- a/app/src/components/channels/avatar.tsx +++ b/app/src/components/channels/avatar.tsx @@ -1,5 +1,6 @@ import Avatar from "boring-avatars"; import { memo } from "react"; +import { cn } from "@/lib/utils"; /** * Memoized roster avatar. Row updates usually change preview/timestamp only, and @@ -7,50 +8,74 @@ import { memo } from "react"; * * `size-full` opts the generated SVG out of ancestor icon selectors such as * `[&_svg:not([class*='size-'])]:size-4`. + * + * `typing` overlays a working indicator at the bottom-right — three bouncing dots, so a channel + * whose agent is mid-turn reads as busy from the roster without moving the row's layout. */ export const ChannelAvatar = memo(function ChannelAvatar({ participantIds, size = 32, + typing = false, }: { participantIds: string[]; size?: number; + typing?: boolean; }) { const channelSize = participantIds?.length; - if (channelSize === 1) { - return ( -
- -
- ); - } - - const firstThree = participantIds.slice(0, 3); - - return ( -
- {firstThree.map((c, i) => { - return ( + const avatar = + channelSize === 1 ? ( + + ) : ( +
+ {participantIds.slice(0, 3).map((c, i, shown) => (
- ); - })} + ))} +
+ ); + + return ( +
+ {avatar} + {typing ? : null}
); }); + +/** + * Three bouncing dots in a small badge, ringed in the sidebar's own colour so it sits on the + * avatar as a badge rather than floating over it. The staggered negative delays start each dot at + * a different point in the same bounce, which is what makes the three read as one wave. + */ +function TypingBadge() { + return ( +
+ Working… + + + +
+ ); +} + +function Dot({ className }: { className?: string }) { + return ( + + ); +} diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 38d013db..2010a386 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -14,8 +14,15 @@ import { transcriptMessages, } from "@/components/channels/transcript-messages"; import { agentListQueryOptions } from "@/lib/agents/queries"; -import { recordChannelActivityMutationOptions } from "@/lib/channels/mutations"; -import type { AgentChannel } from "@/lib/channels/queries"; +import { + recordChannelActivityMutationOptions, + setChannelBusyMutationOptions, +} from "@/lib/channels/mutations"; +import { + type AgentChannel, + type ChannelSummary, + channelKeys, +} from "@/lib/channels/queries"; import { useActiveBot } from "@/lib/copilot/active-bot"; import { ConversationProvider } from "@/lib/copilot/conversation"; import { afterMs, joinWithin } from "@/lib/copilot/join-thread"; @@ -23,6 +30,7 @@ import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history"; import { stoppedReason } from "@/lib/copilot/stopped-turn"; import { readThreadMessages } from "@/lib/copilot/thread-messages"; import { useSkillCommands } from "@/lib/plugins/skill-commands"; +import { queryClient } from "@/query-client"; import { newId } from "../../lib/new-id"; /** @@ -182,6 +190,73 @@ export function ChannelChat({ }; }, [copilotkit, agent, isReady, channel.threadId, runtimeAgentId]); + /* + * A turn nobody here streamed, surfaced while the channel is open. + * + * A relayed handoff answer runs on the server and lands in this thread with no browser attached. + * The roster hears about it — the activity socket patches the channel-list cache — but this + * transcript restores history once, on mount, and would show the new turn only after leaving and + * coming back. So it watches that same cache: when this channel's `lastMessageAt` advances to a + * moment a Bot authored, the durable history is read again. Riding the roster's own cache rather + * than a second subscription means "the sidebar updated" and "the transcript refreshes" are the + * one signal, and cannot drift apart. + * + * APPENDED BY ID, NOT COMPARED BY LENGTH. The stored history is not the local transcript: it + * keeps only what `readableTurns` can parse, and the local side keeps tool lines the platform + * does not hand back — so after a headless turn the stored read can be shorter than the screen + * and still hold the news. What is new is exactly the messages whose ids this transcript has + * never seen; appending them leaves everything local intact, and this tab's own turns echo back + * with ids already on screen and append nothing. + * + * Retried briefly, because the roster is patched when the turn is on record with the runner and + * the platform's read of the thread can be a beat behind it. + */ + useEffect(() => { + const authoredAt = () => { + const cache = queryClient.getQueryData<{ + pages: { channels: ChannelSummary[] }[]; + }>(channelKeys.list()); + const summary = cache?.pages + .flatMap((page) => page.channels) + .find((row) => row.id === channel.id); + // Only a Bot's turn is news here; a person's own line arrives through the run that sent it. + if (!summary || summary.lastMessageAgentId === null) return null; + return summary.lastMessageAt; + }; + + let lastSeen = authoredAt(); + + const pull = () => { + void (async () => { + for (const delayMs of [0, 750, 1500]) { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + const stored = await readThreadMessages( + channel.threadId, + runtimeAgentId, + ); + const current = agentRef.current; + const seen = new Set(current.messages.map((message) => message.id)); + const fresh = stored.messages.filter( + (message) => !seen.has(message.id), + ); + if (fresh.length === 0) continue; + current.setMessages([...current.messages, ...fresh]); + return; + } + })(); + }; + + return queryClient.getQueryCache().subscribe(() => { + const at = authoredAt(); + if (at && at !== lastSeen) { + lastSeen = at; + pull(); + } + }); + }, [channel.id, channel.threadId, runtimeAgentId]); + // Tool calls from this conversation act on this coworker's own computer. useActiveBot(runtimeAgentId); @@ -222,6 +297,24 @@ export function ChannelChat({ * Tell the roster what was just said. Failures here must not block the conversation. */ const recordActivity = useMutation(recordChannelActivityMutationOptions()); + + /* + * Show this channel as working on the roster while its own turn runs. + * + * The server cannot see a person's turn begin — the runtime does not tell it — so the browser + * reports it, keyed on whether a turn is in flight. The server broadcasts it to every member, so + * the row shows the dots even on a tab that has since navigated elsewhere; a run that outlives + * this tab clears itself when the roster next refetches, which is the acceptable failure for a + * transient hint. Not cleared on unmount on purpose: a turn keeps running server-side after the + * person leaves the channel, and clearing here would drop the indicator while the work goes on. + */ + const setBusy = useMutation(setChannelBusyMutationOptions()); + const busy = turnsInFlight > 0; + // Keyed on the busy transition alone; `setBusy.mutate` is a stable handle, not a dependency. + // biome-ignore lint/correctness/useExhaustiveDependencies: firing on the busy transition only. + useEffect(() => { + setBusy.mutate({ channelId: channel.id, busy }); + }, [busy, channel.id]); const report = (text: string, agentId: string | null) => { const trimmed = text.trim(); if (!trimmed) return; diff --git a/app/src/components/channels/composer/composer.tsx b/app/src/components/channels/composer/composer.tsx index dc8ef92b..f642ccfb 100644 --- a/app/src/components/channels/composer/composer.tsx +++ b/app/src/components/channels/composer/composer.tsx @@ -34,6 +34,12 @@ const COMPACT_MAX_HEIGHT_PX = 96; export type ComposerProps = { className?: string; + /** + * Classes for the editor itself rather than the frame. `className` styles the box — border, + * background, width; the type inside it is PromptArea's, so changing it (a hero composer's + * `text-lg`) goes through here, where tailwind-merge lets it beat the built-in `text-sm`. + */ + editorClassName?: string; compact?: boolean; /** Agents that `@` can address. Empty means the mention menu reports an empty channel. */ agents?: readonly AgentOption[]; @@ -94,10 +100,12 @@ export type ComposerProps = { * Defaults to `pending`, which is the right answer for a caller with no gap between the two. */ stoppable?: boolean; + initialValue?: string; }; export function Composer({ className, + editorClassName, compact = false, agents = [], commands = PLACEHOLDER_COMMANDS, @@ -108,8 +116,11 @@ export function Composer({ pending = false, autoFocus = false, stoppable, + initialValue, }: ComposerProps) { - const [value, setValue] = useState([]); + const [value, setValue] = useState( + initialValue ? [{ type: "text", text: initialValue }] : [], + ); const [isSubmitting, setIsSubmitting] = useState(false); const submitInFlight = useRef(false); const promptAreaRef = useRef(null); @@ -292,7 +303,10 @@ export function Composer({
+ ); +} + +/** + * The tour's rhythm, shared by every animated value so they stay in step. + * + * Nine keyframes make eight segments, alternating hold and travel: the cursor pauses where a + * person would, then moves. Easings are per segment for the same reason they differ per value — + * a pointer launches fast and glides in ({@link EASE_OUT}, the app's entrance curve), while a + * click presses sharply and releases with a little pop (`backOut`). One curve across the whole + * loop is what made it read as a metronome. + */ +const TOUR = { + duration: 10, + repeat: Number.POSITIVE_INFINITY, + times: [0, 0.1, 0.28, 0.38, 0.56, 0.66, 0.84, 0.92, 1], +}; +/** Hold segments do not move, so their curve is irrelevant; travel segments glide in. */ +const PATH_EASE = [ + "linear", + [...EASE_OUT], + "linear", + [...EASE_OUT], + "linear", + [...EASE_OUT], + "linear", + [...EASE_OUT], +] as const; +/** The click: a sharp press on each hold, released with a slight overshoot on the way out. */ +const CLICK_EASE = [ + "easeOut", + "backOut", + "easeOut", + "backOut", + "easeOut", + "backOut", + "easeOut", + "backOut", +] as const; + +/** + * The agent's pointer, forever mid-errand: browser button, a card, then the Finder's files. + * + * Percentages rather than pixels, so the same journey fits whatever size the illustration is + * drawn at. + */ +function PointerCursor() { + const reducedMotion = useReducedMotion(); + + return ( + + {/* Tabler's pointer-2, inlined: the installed icon package predates it. */} + + + ); +} + +/** A text line that is deliberately not text. */ +function Line({ className }: { className?: string }) { + return ( +
+ ); +} + +function WindowFrame({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** Traffic lights, monochrome on purpose: the theme's grays, not macOS's colors. */ +function TrafficLights() { + return ( +
+ + + +
+ ); +} + +function BrowserWindow({ className }: { className?: string }) { + return ( + +
+ + {/* The address pill; the empty span mirrors the lights so it centers truly. */} +
+ + +
+ +
+ +
+
+
+ + +
+
+ +
+ + +
+ +
+ {["a", "b", "c"].map((card) => ( +
+
+ + +
+ ))} +
+
+ + ); +} + +/** A document glyph drawn in CSS: a page with a folded corner and two lines of nothing. */ +function FileGlyph() { + return ( +
+
+
+ + +
+
+ ); +} + +function FolderGlyph() { + return ; +} + +function FinderWindow({ className }: { className?: string }) { + const items: Array<{ key: string; folder: boolean }> = [ + { key: "reports", folder: true }, + { key: "invoice", folder: false }, + { key: "assets", folder: true }, + { key: "notes", folder: false }, + { key: "draft", folder: false }, + { key: "archive", folder: true }, + ]; + + return ( + +
+ + + +
+ +
+
+ {["one", "two", "three", "four"].map((row) => ( +
+ + +
+ ))} +
+ +
+ {items.map((item) => ( +
+ {item.folder ? : } + +
+ ))} +
+
+
+ ); +} diff --git a/app/src/components/computer/placeholder.tsx b/app/src/components/computer/placeholder.tsx new file mode 100644 index 00000000..57dfab79 --- /dev/null +++ b/app/src/components/computer/placeholder.tsx @@ -0,0 +1,162 @@ +import type { SVGProps } from "react"; + +/** + * Decorative waiting artwork for the fixed-size computer frame. + */ +export function ComputerPlaceholder(props: SVGProps) { + return ( + + ); +} diff --git a/app/src/lib/auth/queries.ts b/app/src/lib/auth/queries.ts index 6c4ded50..5215e08e 100644 --- a/app/src/lib/auth/queries.ts +++ b/app/src/lib/auth/queries.ts @@ -1,14 +1,32 @@ import { queryOptions } from "@tanstack/react-query"; import { client, tryClient } from "@/lib/client"; +/** + * Where this person is in first-run onboarding. + * + * On the user rather than its own query, so the `_authed` gate learns it from the request it + * already makes. A null `completedAt` is what sends the app to /onboarding. + */ +export type OnboardingStatus = { + step: number; + completedAt: string | null; +}; + export type AuthenticatedUser = { id: string; email: string; name?: string | null; image?: string | null; role: "admin" | "user"; + /** Null means this deployment does not track onboarding, which reads as nothing to finish. */ + onboarding: OnboardingStatus | null; }; +/** Whether the gate holds: there is an onboarding to do and this person has not finished it. */ +export function needsOnboarding(user: AuthenticatedUser): boolean { + return user.onboarding !== null && user.onboarding.completedAt === null; +} + export const authKeys = { all: ["auth"] as const, currentUser: () => [...authKeys.all, "current-user"] as const, diff --git a/app/src/lib/channels/mutations.ts b/app/src/lib/channels/mutations.ts index af83550c..83212ef5 100644 --- a/app/src/lib/channels/mutations.ts +++ b/app/src/lib/channels/mutations.ts @@ -55,6 +55,25 @@ export function recordChannelActivityMutationOptions() { }); } +/** + * Tell the roster this channel is (or is no longer) running a turn. + * + * Fire-and-forget, like recorded activity: a working indicator that fails to appear or to clear is + * worth nothing next to the run itself, and a person is never shown an error for it. The server + * broadcasts it to the channel's members, so the row shows a working dot even on a tab that has + * navigated elsewhere. + */ +export function setChannelBusyMutationOptions() { + return mutationOptions({ + mutationFn: async (variables: { channelId: string; busy: boolean }) => { + await tryClient(`/api/channels/${variables.channelId}/busy`, { + method: "POST", + body: { busy: variables.busy }, + }); + }, + }); +} + /** Pin or unpin a channel for this member. A marker, not a reorder, so no optimistic sort. */ export function setChannelPinnedMutationOptions(queryClient: QueryClient) { return mutationOptions({ diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts index 6da66bf1..4a62ce05 100644 --- a/app/src/lib/channels/queries.ts +++ b/app/src/lib/channels/queries.ts @@ -28,6 +28,15 @@ export type ChannelSummary = AgentChannel & { pinned: boolean; /** ISO-8601 when this member last had the channel open, or null for never. The caller's, only. */ lastReadAt: string | null; + /** + * Whether a turn is running in this channel right now. + * + * Socket-only and transient: the server never persists it and the roster query never returns it, + * so it is undefined until a busy event arrives and is dropped whenever the roster is refetched. + * A headless turn — a handoff hop, a relay — sets it, which is how the roster shows work the + * browser never streamed. + */ + busy?: boolean; }; export const channelKeys = { diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts index 70ffab50..8bd1df46 100644 --- a/app/src/lib/channels/use-channel-events.ts +++ b/app/src/lib/channels/use-channel-events.ts @@ -24,6 +24,13 @@ export type ChannelActivityEvent = { * made in another tab or on another replica. */ pinned?: boolean; + /** + * A turn started or ended in this channel. Absent on an ordinary activity event. + * + * Carries no message: it patches only the row's `busy` flag, so the roster can show a working + * indicator without disturbing the preview or the order. + */ + busy?: boolean; }; /** The infinite query's cache, which holds pages rather than one array. */ @@ -88,6 +95,22 @@ export function applyChannelEvent( return { ...data, pages }; } + /* + * A busy signal patches the one field it is about, and never re-sorts. + * + * The spread below would carry this event's null message onto the row and wipe the preview. Busy + * is also not activity — a channel does not jump to the top of the roster because a turn started + * in it — so the order is left exactly as it was. + */ + if (activity.busy !== undefined) { + if ((previous.busy ?? false) === activity.busy) return data; + const channels = page.channels.slice(); + channels[index] = { ...previous, busy: activity.busy }; + const pages = data.pages.slice(); + pages[holdingPage] = { ...page, channels }; + return { ...data, pages }; + } + // Preserve object identity for unchanged rows so memoized rows do not re-render. const next = page.channels.slice(); next[index] = { ...previous, ...activity }; diff --git a/app/src/lib/hotkeys/app-hotkeys.tsx b/app/src/lib/hotkeys/app-hotkeys.tsx new file mode 100644 index 00000000..62c9ccc0 --- /dev/null +++ b/app/src/lib/hotkeys/app-hotkeys.tsx @@ -0,0 +1,21 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useHotkey } from "./use-hotkey"; + +/** + * The app-wide shortcuts, bound once for the whole signed-in app. + * + * Mounted in `_authed` rather than `_app`, so a person on settings or admin can start a chat + * without first clicking back into the app frame. Renders nothing; it exists to be a component + * because binding needs hooks and `_authed`'s route component is where the whole signed-in tree + * hangs. + */ +export function AppHotkeys() { + const navigate = useNavigate(); + + // Same destination as the sidebar's + button: the new-channel composer. + useHotkey("new-chat", () => { + navigate({ to: "/channel/new" }); + }); + + return null; +} diff --git a/app/src/lib/hotkeys/hotkeys.ts b/app/src/lib/hotkeys/hotkeys.ts new file mode 100644 index 00000000..7aa30361 --- /dev/null +++ b/app/src/lib/hotkeys/hotkeys.ts @@ -0,0 +1,89 @@ +/** + * Every app-wide keyboard shortcut, in one place. + * + * One place on purpose: the binding a listener matches against and the combo the settings page + * shows a person are the same entry, so they cannot drift apart. Adding a shortcut is adding an + * entry here and one `useHotkey` call where it acts; the settings list picks it up by itself. + * + * Browsers reserve their own combos. Cmd/Ctrl+N — the obvious key for "new chat" — opens a new + * browser window before the page ever sees the keystroke, and `preventDefault` is ignored for + * reserved shortcuts, so no entry here can use one. Plain Shift+letter combos work everywhere, + * at the cost that they are also just how capital letters are typed — which is why `useHotkey` + * ignores them while focus is in anything editable. + */ + +export type HotkeyCombo = { + /** KeyboardEvent.key, lowercase. */ + key: string; + shift?: boolean; + /** Cmd on macOS, Ctrl elsewhere. */ + mod?: boolean; + alt?: boolean; +}; + +export type Hotkey = { + id: string; + /** What the shortcut does, as the settings page says it. */ + label: string; + description: string; + combo: HotkeyCombo; +}; + +export const HOTKEYS = [ + { + id: "new-chat", + label: "New chat", + description: "Start a new chat from anywhere in the app.", + combo: { key: "n", shift: true }, + }, +] as const satisfies readonly Hotkey[]; + +export type HotkeyId = (typeof HOTKEYS)[number]["id"]; + +export function getHotkey(id: HotkeyId): Hotkey { + const hotkey = HOTKEYS.find((candidate) => candidate.id === id); + if (!hotkey) { + throw new Error(`Unknown hotkey "${id}".`); + } + return hotkey; +} + +const isMac = + typeof navigator !== "undefined" && + /Mac|iPhone|iPad/.test(navigator.platform); + +/** + * Whether this keystroke is this combo — exactly, not at-least. + * + * Every modifier is compared, including the ones the combo does not ask for: Shift+N must not + * fire on Cmd+Shift+N, or the combo would shadow whatever that means to the browser. `mod` is + * Cmd on a Mac and Ctrl elsewhere, and the one it is not still has to be up. + */ +export function matchesHotkey( + event: KeyboardEvent, + combo: HotkeyCombo, +): boolean { + const mod = isMac ? event.metaKey : event.ctrlKey; + const otherMod = isMac ? event.ctrlKey : event.metaKey; + return ( + event.key.toLowerCase() === combo.key && + event.shiftKey === Boolean(combo.shift) && + mod === Boolean(combo.mod) && + !otherMod && + event.altKey === Boolean(combo.alt) + ); +} + +/** + * The combo as a person's keyboard writes it, one part per key: symbols on a Mac (["⇧", "N"]), + * names everywhere else (["Shift", "N"]). One entry per physical key so each can be drawn as + * its own keycap. + */ +export function formatHotkey(combo: HotkeyCombo): string[] { + const parts: string[] = []; + if (combo.mod) parts.push(isMac ? "⌘" : "Ctrl"); + if (combo.alt) parts.push(isMac ? "⌥" : "Alt"); + if (combo.shift) parts.push(isMac ? "⇧" : "Shift"); + parts.push(combo.key.toUpperCase()); + return parts; +} diff --git a/app/src/lib/hotkeys/use-hotkey.ts b/app/src/lib/hotkeys/use-hotkey.ts new file mode 100644 index 00000000..6f88ca1a --- /dev/null +++ b/app/src/lib/hotkeys/use-hotkey.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; +import { getHotkey, matchesHotkey } from "./hotkeys"; +import type { HotkeyId } from "./hotkeys"; + +/** + * Whether the keystroke belongs to whatever the person is typing into. + * + * A combo without a modifier is also just a character: Shift+N is how "New York" starts. A + * shortcut that fires mid-word steals the letter and throws away the composer the person was + * writing in, so anything editable — inputs, textareas, contenteditable transcripts — swallows + * the event as far as un-modified hotkeys are concerned. + */ +function isEditable(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + return ( + target.isContentEditable || + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" + ); +} + +/** + * Runs `handler` when the registered combo for `id` is pressed anywhere on the page. + * + * The combo itself lives in the registry, not at the call site, so the settings page and the + * listener can never disagree about what the key is. The handler rides in a ref: it closes over + * render-time state, and re-binding a window listener on every render is churn the ref avoids. + */ +export function useHotkey(id: HotkeyId, handler: () => void) { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + const { combo } = getHotkey(id); + const onKeyDown = (event: KeyboardEvent) => { + if (!matchesHotkey(event, combo)) { + return; + } + if (!combo.mod && !combo.alt && isEditable(event.target)) { + return; + } + event.preventDefault(); + handlerRef.current(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [id]); +} diff --git a/app/src/lib/onboarding/mutations.ts b/app/src/lib/onboarding/mutations.ts new file mode 100644 index 00000000..0bef12a3 --- /dev/null +++ b/app/src/lib/onboarding/mutations.ts @@ -0,0 +1,48 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { authKeys, type OnboardingStatus } from "@/lib/auth/queries"; +import { client } from "@/lib/client"; + +/** The sentence for every write here, since they all fail the same way to a reader. */ +const FALLBACK = "Onboarding could not be saved"; + +/** + * The status lives on the current user, so that is what a write refreshes. + * + * No `onboardingKeys` on purpose: a key nothing reads would be a cache entry nothing invalidates. + * The `_authed` gate and the wizard both read `currentUserQueryOptions`. + * + * `refetchType: "all"`, because nothing may be observing that query: the gate reads it through + * `ensureQueryData`, which takes the cache as it finds it. The default only refetches active + * queries, so completing onboarding left a stale "not onboarded" user behind and the gate bounced + * the navigation straight back to the wizard. + */ +function invalidateCurrentUser(queryClient: QueryClient) { + return queryClient.invalidateQueries({ + queryKey: authKeys.currentUser(), + refetchType: "all", + }); +} + +export function advanceOnboardingMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: (step: number): Promise => + client("/api/me/onboarding", "onboarding", { + method: "POST", + body: { step }, + fallback: FALLBACK, + }), + onSuccess: () => invalidateCurrentUser(queryClient), + }); +} + +export function completeOnboardingMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: (): Promise => + client("/api/me/onboarding", "onboarding", { + method: "POST", + body: { completed: true }, + fallback: FALLBACK, + }), + onSuccess: () => invalidateCurrentUser(queryClient), + }); +} diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 2ca64fab..ac241081 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as AuthedRouteImport } from './routes/_authed' import { Route as SignRouteImport } from './routes/sign' import { Route as AuthedAppRouteImport } from './routes/_authed/_app' import { Route as AuthedAdminRouteRouteImport } from './routes/_authed/admin/route' +import { Route as AuthedOnboardingRouteImport } from './routes/_authed/onboarding' import { Route as AuthedSettingsRouteRouteImport } from './routes/_authed/settings/route' import { Route as AuthedAppIndexRouteImport } from './routes/_authed/_app/index' import { Route as AuthedAppBotRouteImport } from './routes/_authed/_app/bot' @@ -59,6 +60,11 @@ const AuthedAdminRouteRoute = AuthedAdminRouteRouteImport.update({ path: '/admin', getParentRoute: () => AuthedRoute, } as any) +const AuthedOnboardingRoute = AuthedOnboardingRouteImport.update({ + id: '/onboarding', + path: '/onboarding', + getParentRoute: () => AuthedRoute, +} as any) const AuthedSettingsRouteRoute = AuthedSettingsRouteRouteImport.update({ id: '/settings', path: '/settings', @@ -209,6 +215,7 @@ export interface FileRoutesByFullPath { '/sign': typeof SignRoute '/admin': typeof AuthedAdminRouteRouteWithChildren '/settings': typeof AuthedSettingsRouteRouteWithChildren + '/onboarding': typeof AuthedOnboardingRoute '/bot': typeof AuthedAppBotRoute '/routines': typeof AuthedAppRoutinesRoute '/skills': typeof AuthedAppSkillsRoute @@ -238,6 +245,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof AuthedAppIndexRoute '/sign': typeof SignRoute + '/onboarding': typeof AuthedOnboardingRoute '/bot': typeof AuthedAppBotRoute '/routines': typeof AuthedAppRoutinesRoute '/skills': typeof AuthedAppSkillsRoute @@ -271,6 +279,7 @@ export interface FileRoutesById { '/_authed/admin': typeof AuthedAdminRouteRouteWithChildren '/_authed/settings': typeof AuthedSettingsRouteRouteWithChildren '/_authed/_app': typeof AuthedAppRouteWithChildren + '/_authed/onboarding': typeof AuthedOnboardingRoute '/_authed/_app/bot': typeof AuthedAppBotRoute '/_authed/_app/routines': typeof AuthedAppRoutinesRoute '/_authed/_app/skills': typeof AuthedAppSkillsRoute @@ -305,6 +314,7 @@ export interface FileRouteTypes { | '/sign' | '/admin' | '/settings' + | '/onboarding' | '/bot' | '/routines' | '/skills' @@ -334,6 +344,7 @@ export interface FileRouteTypes { to: | '/' | '/sign' + | '/onboarding' | '/bot' | '/routines' | '/skills' @@ -366,6 +377,7 @@ export interface FileRouteTypes { | '/_authed/admin' | '/_authed/settings' | '/_authed/_app' + | '/_authed/onboarding' | '/_authed/_app/bot' | '/_authed/_app/routines' | '/_authed/_app/skills' @@ -429,6 +441,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminRouteRouteImport parentRoute: typeof AuthedRoute } + '/_authed/onboarding': { + id: '/_authed/onboarding' + path: '/onboarding' + fullPath: '/onboarding' + preLoaderRoute: typeof AuthedOnboardingRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/settings': { id: '/_authed/settings' path: '/settings' @@ -709,12 +728,14 @@ interface AuthedRouteChildren { AuthedAdminRouteRoute: typeof AuthedAdminRouteRouteWithChildren AuthedSettingsRouteRoute: typeof AuthedSettingsRouteRouteWithChildren AuthedAppRoute: typeof AuthedAppRouteWithChildren + AuthedOnboardingRoute: typeof AuthedOnboardingRoute } const AuthedRouteChildren: AuthedRouteChildren = { AuthedAdminRouteRoute: AuthedAdminRouteRouteWithChildren, AuthedSettingsRouteRoute: AuthedSettingsRouteRouteWithChildren, AuthedAppRoute: AuthedAppRouteWithChildren, + AuthedOnboardingRoute: AuthedOnboardingRoute, } const AuthedRouteWithChildren = diff --git a/app/src/routes/__root.tsx b/app/src/routes/__root.tsx index 645f935d..e8752606 100644 --- a/app/src/routes/__root.tsx +++ b/app/src/routes/__root.tsx @@ -1,4 +1,8 @@ -import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; +import { + createRootRouteWithContext, + Navigate, + Outlet, +} from "@tanstack/react-router"; import { ThemeProvider } from "@/components/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; import type { RouterContext } from "../router-context"; @@ -6,6 +10,12 @@ import "@fontsource-variable/inter/wght.css"; export const Route = createRootRouteWithContext()({ component: RootComponent, + /* + * An address this build does not know goes home rather than to a dead end. Home sits behind the + * auth gate, so the guard decides what that means: /sign for a visitor, /onboarding for somebody + * who has not finished it, the app for everyone else. + */ + notFoundComponent: () => , }); function RootComponent() { diff --git a/app/src/routes/_authed.tsx b/app/src/routes/_authed.tsx index f3742d54..2e34d837 100644 --- a/app/src/routes/_authed.tsx +++ b/app/src/routes/_authed.tsx @@ -1,20 +1,30 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; -import { currentUserQueryOptions } from "../lib/auth/queries"; +import { currentUserQueryOptions, needsOnboarding } from "../lib/auth/queries"; import { CopilotProvider } from "../lib/copilot/provider"; +import { AppHotkeys } from "../lib/hotkeys/app-hotkeys"; export const Route = createFileRoute("/_authed")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context, location }) => { const user = await context.queryClient.ensureQueryData( currentUserQueryOptions(), ); if (!user) { throw redirect({ to: "/sign" }); } + /* + * Somebody who has not finished onboarding goes there and nowhere else. Here rather than in + * `_app`, so admin and settings are behind the same gate; checked against the destination so + * the onboarding route itself stays reachable. + */ + if (needsOnboarding(user) && location.pathname !== "/onboarding") { + throw redirect({ to: "/onboarding" }); + } }, // 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: () => ( + ), diff --git a/app/src/routes/_authed/_app/channel/new.tsx b/app/src/routes/_authed/_app/channel/new.tsx index 100f7b3e..a969ba3f 100644 --- a/app/src/routes/_authed/_app/channel/new.tsx +++ b/app/src/routes/_authed/_app/channel/new.tsx @@ -85,6 +85,10 @@ function RouteComponent() { value={chosen ?? null} > { + const user = await context.queryClient.ensureQueryData( + currentUserQueryOptions(), + ); + // Somebody who has finished, or whose deployment tracks no onboarding, has no business here. + if (!user || !needsOnboarding(user)) { + throw redirect({ to: "/" }); + } + }, + component: RouteComponent, +}); + +function WelcomeStep() { + return ( +
+

+ Welcome to {appConfig.brand.productName} +

+
+ +
+ +
+
+ ); +} + +function ComputerUseStep() { + return ( +
+

+ Each agent has its own computer +

+
+
+ + +
+
+ ); +} + +/** What a roster card needs — placeholders carry these three fields and nothing more. */ +type RosterCard = Pick; + +/** + * Stand-ins for a deployment that has fewer than three public agents to show. Invented names on + * purpose: they illustrate what a roster looks like without claiming any of these exist here. + */ +const AGENTS_PLACEHOLDER: RosterCard[] = [ + { + id: "placeholder-research", + name: "Research Analyst", + avatarSeed: "research-analyst", + }, + { id: "placeholder-data", name: "Data Analyst", avatarSeed: "data-analyst" }, + { + id: "placeholder-support", + name: "Support Agent", + avatarSeed: "support-agent", + }, +]; + +function RosterStep() { + const { data: agents } = useQuery(agentListQueryOptions()); + const explore = + agents?.filter((a) => !a.mine && a.visibility === "public") ?? []; + // Always three cards: real public agents first, placeholders topping up a sparse deployment. + // slice past the end is just [], so a roster of three or more takes no placeholders at all. + const roster: RosterCard[] = [ + ...explore.slice(0, 3), + ...AGENTS_PLACEHOLDER.slice(explore.length), + ]; + + return ( +
+

+ Choose from a variety of agents or create your own +

+
+
+ {roster.map((a) => { + return ( +
+ +

+ {a.name} +

+
+ ); + })} +
+
+

+ Your own agent +

+
+
+
+ ); +} + +const STEPS: Array<() => React.ReactNode> = [ + () => , + () => , + () => , +]; + +/** A pane arrives from the side the journey is moving toward, and leaves out the other. */ +const variants = { + initial: (direction: number) => ({ x: `${110 * direction}%`, opacity: 0 }), + active: { x: "0%", opacity: 1 }, + exit: (direction: number) => ({ x: `${-110 * direction}%`, opacity: 0 }), +}; + +function RouteComponent() { + const navigate = useNavigate(); + const complete = useMutation(completeOnboardingMutationOptions(queryClient)); + + // Browser state on purpose: the step is not persisted while the wizard is being designed. + const [step, setStep] = React.useState(0); + const [direction, setDirection] = React.useState(1); + // The way out: set once the completion is saved, it fades the whole page and then navigates. + const [leaving, setLeaving] = React.useState(false); + const [ref, bounds] = useMeasure(); + + const last = step === STEPS.length - 1; + + const go = (to: number) => { + setDirection(to > step ? 1 : -1); + setStep(to); + }; + + return ( + // Outside `_app` on purpose: no sidebar and no chrome until onboarding is done. + // The fade runs only after the completion is saved, so a failed save never fades a page the + // person still needs — and navigation waits for the fade, so the home screen never pops in + // over a half-faded wizard. + { + if (leaving) { + navigate({ to: "/" }); + } + }} + transition={{ duration: 0.5, ease: "easeInOut" }} + > +
+ + {/* The frame follows each pane's height, so the buttons glide instead of jumping. */} + 0 ? bounds.height : "auto" }} + className="overflow-hidden" + > +
+ + + {STEPS[step]()} + + + + {complete.error ? ( +

+ {complete.error.message} +

+ ) : null} + + + + {step !== 0 && ( + + )} + +
+
+
+
+
+ ); +} diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx index 9b4991da..2fba05d2 100644 --- a/app/src/routes/_authed/settings/index.tsx +++ b/app/src/routes/_authed/settings/index.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; +import React from "react"; import { PageRows, PageSection, @@ -12,7 +13,9 @@ import { ItemDescription, ItemTitle, } from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; +import { formatHotkey, HOTKEYS } from "@/lib/hotkeys/hotkeys"; export const Route = createFileRoute("/_authed/settings/")({ component: RouteComponent, @@ -53,6 +56,38 @@ function RouteComponent() { + {/* + * Drawn from the same registry the listeners match against, so this list is what the keys + * actually do rather than what somebody remembered they did. Read-only on purpose: these + * are not rebindable, and a row with nothing to click says so by having nothing to click. + */} + + + {HOTKEYS.map((hotkey, index) => ( + + + + {hotkey.label} + {hotkey.description} + + + + {formatHotkey(hotkey.combo).map((part) => ( + + {part} + + ))} + + + + {index !== HOTKEYS.length - 1 && } + + ))} + + ); } diff --git a/app/tests/channel-event-patch.test.ts b/app/tests/channel-event-patch.test.ts index 62efb793..6f073ea6 100644 --- a/app/tests/channel-event-patch.test.ts +++ b/app/tests/channel-event-patch.test.ts @@ -169,3 +169,56 @@ describe("a pin", () => { ).toBe(data); }); }); + +/** + * A busy signal: a turn started or ended in the channel. + * + * Server-side headless work — a handoff hop, a relay — that no browser streamed, surfaced on the + * roster as a working indicator. Message-less on purpose: it must not disturb the preview or the + * order the way an ordinary activity event does. + */ +describe("a busy signal", () => { + test("patches only the busy flag, leaving the last message and order alone", () => { + const data = cache([ + channel("a", { + lastMessage: "Said something.", + lastMessageAt: "2024-04-01T00:00:00.000Z", + lastMessageAgentId: "agent-1", + }), + channel("b", { lastMessageAt: "2024-05-01T00:00:00.000Z" }), + ]); + + const patched = applyChannelEvent( + data, + event({ channelId: "a", busy: true }), + ); + + expect(patched).not.toBe("unknown"); + if (patched === "unknown") return; + // Only `busy` changed on row a; its message survives, and b did not jump ahead of it. + expect(patched.pages[0]?.channels.map((row) => row.id)).toEqual(["a", "b"]); + expect(patched.pages[0]?.channels[0]).toEqual({ + ...(data.pages[0]?.channels[0] as ChannelSummary), + busy: true, + }); + }); + + test("clears the same way", () => { + const patched = applyChannelEvent( + cache([channel("a", { busy: true })]), + event({ channelId: "a", busy: false }), + ); + + expect(patched).not.toBe("unknown"); + if (patched === "unknown") return; + expect(patched.pages[0]?.channels[0]?.busy).toBe(false); + }); + + test("returns the same cache when the row already says so", () => { + const data = cache([channel("a", { busy: true })]); + + expect(applyChannelEvent(data, event({ channelId: "a", busy: true }))).toBe( + data, + ); + }); +}); diff --git a/bun.lock b/bun.lock index 1ac087cf..415b9ce6 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "@shadcn/react": "^0.3.0", "@tabler/icons-react": "^3.36.1", "@tanstack/react-form": "^1.33.5", + "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.27", "better-auth": "^1.6.27", @@ -35,6 +36,7 @@ "prompt-area": "^0.6.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-use-measure": "^2.1.7", "shadcn": "^4.17.0", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", @@ -659,6 +661,8 @@ "@tanstack/history": ["@tanstack/history@1.162.1", "", {}, "sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w=="], + "@tanstack/hotkeys": ["@tanstack/hotkeys@0.8.0", "", { "dependencies": { "@tanstack/store": "^0.11.0" } }, "sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA=="], + "@tanstack/pacer": ["@tanstack/pacer@0.20.1", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.3", "@tanstack/store": "^0.9.3" } }, "sha512-ZNQ1bIL6eUXVKdic0tiImvBVkWrg/IoSK6VIacTrO3d3HAGnd70qFJNJagR/YOJIOw4EKGWnodwpYZkN1pWuVQ=="], "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], @@ -667,6 +671,8 @@ "@tanstack/react-form": ["@tanstack/react-form@1.33.5", "", { "dependencies": { "@tanstack/form-core": "1.33.5", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "@tanstack/react-start": "*", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@tanstack/react-start"] }, "sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA=="], + "@tanstack/react-hotkeys": ["@tanstack/react-hotkeys@0.10.0", "", { "dependencies": { "@tanstack/hotkeys": "0.8.0", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-GwOSndI5j3qBVYTmgP1mYyRTnlxb2MS17cwGlsavSxMQPSnmDf+m3LzMIpRMs+3zzQMjg3cYhHsFYizYlFI2tw=="], + "@tanstack/react-query": ["@tanstack/react-query@5.102.2", "", { "dependencies": { "@tanstack/query-core": "5.102.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-KxU8ZyOEuJ81eTSgXa8GQbk/jO/rz0elYtNKt3VMtM2pRjeO8ADIu7sqqmEjFKJKqco9h2N1ojIDuHs6VfDItQ=="], "@tanstack/react-router": ["@tanstack/react-router@1.170.32", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.27", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw=="], @@ -1885,6 +1891,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], diff --git a/server/drizzle/0024_onboarding.sql b/server/drizzle/0024_onboarding.sql new file mode 100644 index 00000000..6bf277d3 --- /dev/null +++ b/server/drizzle/0024_onboarding.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" ADD COLUMN "onboarding_step" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "onboarding_completed_at" timestamp with time zone; \ No newline at end of file diff --git a/server/drizzle/0025_backfill_existing_users_have_onboarded.sql b/server/drizzle/0025_backfill_existing_users_have_onboarded.sql new file mode 100644 index 00000000..3acad853 --- /dev/null +++ b/server/drizzle/0025_backfill_existing_users_have_onboarded.sql @@ -0,0 +1,10 @@ +-- Everybody who exists before this migration has already been using the product, and the wizard +-- behind the new gate teaches nothing they need. A null completion is what sends a person to +-- /onboarding, so anyone here when the column arrives is stamped as done and only people who sign +-- in for the first time from now on see it. +-- +-- Written through `drizzle-kit generate --custom`, like 0003: "the people who already exist have +-- onboarded" is a fact about rows, not about the schema, so no diff can produce it. +UPDATE "users" +SET "onboarding_completed_at" = now() +WHERE "onboarding_completed_at" IS NULL; diff --git a/server/drizzle/meta/0024_snapshot.json b/server/drizzle/meta/0024_snapshot.json new file mode 100644 index 00000000..742e2190 --- /dev/null +++ b/server/drizzle/meta/0024_snapshot.json @@ -0,0 +1,3020 @@ +{ + "id": "bcc734d4-d33c-4b1c-a315-06dde9e016bf", + "prevId": "a1053424-96eb-4ace-98e4-63ac0ea99060", + "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 + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "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 + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_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": { + "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 + }, + "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": { + "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.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": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.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.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "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.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "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": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_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": { + "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.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 + }, + "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.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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 + } + }, + "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 + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_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": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": "uuid", + "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": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "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.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_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": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "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.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "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 + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0025_snapshot.json b/server/drizzle/meta/0025_snapshot.json new file mode 100644 index 00000000..c11e750c --- /dev/null +++ b/server/drizzle/meta/0025_snapshot.json @@ -0,0 +1,3020 @@ +{ + "id": "d96da430-35aa-475c-a060-f55151269d6d", + "prevId": "bcc734d4-d33c-4b1c-a315-06dde9e016bf", + "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 + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "package_id" + ], + "tableTo": "deployment_packages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "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", + "columnsFrom": [ + "channel_id" + ], + "tableTo": "channels", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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 + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_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": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "columnsFrom": [ + "channel_id" + ], + "tableTo": "channels", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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 + }, + "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": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "columnsFrom": [ + "package_id" + ], + "tableTo": "deployment_packages", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "columnsFrom": [ + "last_message_agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "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": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false + } + }, + "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", + "columns": [ + "tenant_id" + ], + "nullsNotDistinct": false + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "columnsFrom": [ + "channel_id" + ], + "tableTo": "channels", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "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", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "columns": [ + "provider_id" + ], + "nullsNotDistinct": false + } + }, + "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", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_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": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "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.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 + }, + "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.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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 + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "columnsFrom": [ + "owner_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "columnsFrom": [ + "routine_id" + ], + "tableTo": "routines", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_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": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "columnsFrom": [ + "owner_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "component_name" + ], + "tableTo": "components", + "columnsTo": [ + "name" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "component_name" + ], + "tableTo": "components", + "columnsTo": [ + "name" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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": "uuid", + "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": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "columnsFrom": [ + "credential_id" + ], + "tableTo": "credentials", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "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", + "columnsFrom": [ + "server_id" + ], + "tableTo": "mcp_servers", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_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": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "columnsFrom": [ + "server_id" + ], + "tableTo": "mcp_servers", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "columnsFrom": [ + "credential_id" + ], + "tableTo": "credentials", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "columnsFrom": [ + "skill_id" + ], + "tableTo": "skills", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "columnsFrom": [ + "owner_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_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 b6330597..0a0fb463 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -169,6 +169,20 @@ "when": 1787841174859, "tag": "0023_routines_owner_index", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1787926466892, + "tag": "0024_onboarding", + "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1787926472382, + "tag": "0025_backfill_existing_users_have_onboarded", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/server/src/agents/handoff-delivery.ts b/server/src/agents/handoff-delivery.ts index fdf07502..6ea276e5 100644 --- a/server/src/agents/handoff-delivery.ts +++ b/server/src/agents/handoff-delivery.ts @@ -86,7 +86,7 @@ export function createHandoffDelivery(options: { runner: ThreadRunner; lock: ThreadLock; /** - * Where the addressed Bot answers: a conversation of its own with the same person. + * A fresh thread of the addressed Bot's own, where its working happens out of sight. * * NOT THE CONVERSATION THAT ASKED, and this is a property of the platform rather than a choice. An * Intelligence thread is owned by exactly one agent: `assertThreadAgentOwnership` refuses any other @@ -94,29 +94,43 @@ export function createHandoffDelivery(options: { * second Bot answering inside the first Bot's thread is not something this platform can express * today, whatever the caller does. * - * So the answer lands where that Bot can speak, and the conversation that asked says where it went. - * The person gets both halves; they are two conversations rather than one, which is the honest - * shape of what actually happened. + * A scratch thread rather than the Bot's own channel with the person, which is where answers used + * to land. Two conversations for one question meant the person read the answer somewhere they + * never asked anything; now the runner relays what came back through the Bot that asked, in the + * conversation they are actually watching, and the scratch thread is never mapped to a channel so + * nobody is shown it. It still exists on the platform, which is what makes the turn a recorded + * run rather than an unlogged model call. */ - answerIn: (input: { - actorId: string; - botId: string; - }) => Promise<{ threadId: string; channelId?: string }>; + mintThreadId: () => string; /** - * Tell the roster this conversation moved. + * Tell the roster a conversation moved, when a turn put words in it. * - * A HOP HAS NOBODY WATCHING, which is exactly why this is needed here. A conversation's place in - * the list and the line under its name are written by the browser when somebody's own run - * finishes; a hop finishes on a server with no browser attached, so without this the answer lands - * in a conversation that still says it was last used yesterday and sits where it was. The person - * is never told, and the whole point of a hop is that they find out. + * A HOP HAS NOBODY WATCHING. A conversation's place in the list and the line under its name are + * written by the browser when somebody's own run finishes; a hop finishes on a server with no + * browser attached, so without this a relayed answer lands in a conversation that never bumps, + * never reads as unread, and is found by accident. Routines have the same problem and the same + * answer: `recordActivity`, from the server, when the turn is on record. + * + * Keyed by thread rather than channel, because the thread is all a delivery knows. The wiring + * resolves which channel shows that thread — and a scratch thread resolves to nothing, which is + * a no-op rather than a mistake. */ announce?: (input: { actorId: string; - channelId: string; + threadId: string; agentId: string; text: string; }) => Promise; + /** + * Show the asking conversation as working while a FORWARD hop runs, and stop when it is over. + * + * Only the forward leg, and keyed on the asking thread — `work.threadId`, the conversation the + * person is waiting in — because that leg runs in a scratch thread nobody watches. A backwards + * hop runs in the asking thread itself, whose lock the runtime already lights through its own + * busy signal, so this leaves that leg alone rather than double it. Best-effort and paired in a + * `finally`, so a channel never stays lit because a run threw. + */ + setBusy?: (input: { threadId: string; busy: boolean }) => Promise; newRunId: () => string; /** * How long one delivery may take before it is given up on. @@ -135,8 +149,9 @@ export function createHandoffDelivery(options: { history, runner, lock, - answerIn, + mintThreadId, announce, + setBusy, newRunId, deadlineMs = DEFAULT_DELIVERY_DEADLINE_MS, } = options; @@ -170,6 +185,17 @@ export function createHandoffDelivery(options: { * calls its methods, and a stand-in that proxies them is a second thing to keep in step. */ const seen = { count: 0, last: "" }; + /* + * What the addressed Bot said, gathered from the stream as it goes past. + * + * The runner publishes the turn to the platform rather than back through the observable, so + * the text exists nowhere this function can read after the fact — the events are the one + * chance to hear it. It is what the relay carries back to the conversation that asked, and + * with the answer no longer landing in a channel of its own, this is the only copy a person + * will ever be shown. + */ + const said: string[] = []; + let saying = ""; const runAgent = typeof agent.runAgent === "function" ? agent.runAgent.bind(agent) @@ -183,173 +209,219 @@ export function createHandoffDelivery(options: { input as never, { ...(config ?? {}), - onEvent: (emitted: { event?: { type?: unknown } }) => { + onEvent: (emitted: { + event?: { type?: unknown; delta?: unknown }; + }) => { seen.count += 1; - seen.last = String(emitted?.event?.type ?? ""); + const type = String(emitted?.event?.type ?? ""); + seen.last = type; + if (type === "TEXT_MESSAGE_START") saying = ""; + if (type === "TEXT_MESSAGE_CONTENT") + saying += String(emitted?.event?.delta ?? ""); + if (type === "TEXT_MESSAGE_END" && saying.trim().length > 0) + said.push(saying.trim()); config?.onEvent?.(emitted); }, } as never, ); /* - * The conversation this answer belongs in. + * The conversation this turn runs in. * - * Named on the hop for the one kind that goes backwards: telling the asking Bot, where the - * person is watching, that the Bot it asked never came back. Every other hop lands in the - * addressed Bot's own conversation, because a thread has exactly one agent. + * Named on the hop for the kind that goes backwards — the asking Bot speaking in the + * conversation the person is watching, to relay an answer or a failure. Every forward hop + * runs in a scratch thread of the addressed Bot's own, because a thread has exactly one + * agent; what it says there comes back to the person through the relay, not the thread. */ - const where: { threadId: string; channelId?: string } = work.answerIn + const where: { threadId: string } = work.answerIn ? { threadId: work.answerIn } - : await answerIn({ actorId: work.actorId, botId: work.toBotId }); + : { threadId: mintThreadId() }; /* - * The conversation's lock, before a single event is streamed. - * - * The platform's run id is the one it hands back, not the one asked for: it is the identity the - * gateway will check every streamed event against, so using the local one would be claiming to - * be a run that does not exist. + * A forward hop lights the asking channel while it runs, because its own run is in a scratch + * thread nobody sees. A backwards hop — a relay or a notice — runs in the asking thread + * itself, so the runtime's own thread lock already lights it through `onRunBusy`, and + * signalling here too would double up. So this covers only the leg the lock cannot: the + * addressed Bot thinking, off-screen, on behalf of a channel the person is watching. */ - const held = await lock.acquire({ - threadId: where.threadId, - runId: newRunId(), - userId: work.actorId, - agentId: work.toBotId, - }); - if (!held) { - /* - * Somebody else is running in this conversation. Thrown so the hop goes back on the queue - * and is tried again: a person mid-question, or the Bot that asked still finishing its own - * sentence, is a wait rather than a failure. - */ - throw new Error( - `${where.threadId} is busy with another run; the hop will be tried again`, + const lightsAskingChannel = !work.answerIn; + if (lightsAskingChannel) { + await setBusy?.({ threadId: work.threadId, busy: true }).catch( + () => {}, ); } + try { + /* + * The conversation's lock, before a single event is streamed. + * + * The platform's run id is the one it hands back, not the one asked for: it is the identity the + * gateway will check every streamed event against, so using the local one would be claiming to + * be a run that does not exist. + */ + const held = await lock.acquire({ + threadId: where.threadId, + runId: newRunId(), + userId: work.actorId, + agentId: work.toBotId, + }); + if (!held) { + /* + * Somebody else is running in this conversation. Thrown so the hop goes back on the queue + * and is tried again: a person mid-question, or the Bot that asked still finishing its own + * sentence, is a wait rather than a failure. + */ + throw new Error( + `${where.threadId} is busy with another run; the hop will be tried again`, + ); + } - const runId = held.runId; + const runId = held.runId; - /* - * THE CONVERSATION GOES ON THE AGENT, NOT IN THE RUN. - * - * `runAgent` takes `runId`, `tools`, `context` and `forwardedProps` and nothing else: AG-UI - * keeps the messages and the thread on the agent itself, and builds the run's input from them. - * A `messages` array passed as a parameter is silently ignored, which is the worst shape a - * mistake can take. Nothing failed. The addressed Bot ran, against an empty conversation, and - * answered "how can I help?" to a question it had never been shown, in a transcript that - * displayed the question directly above the answer. - */ - const asked = [ + /* + * THE CONVERSATION GOES ON THE AGENT, NOT IN THE RUN. + * + * `runAgent` takes `runId`, `tools`, `context` and `forwardedProps` and nothing else: AG-UI + * keeps the messages and the thread on the agent itself, and builds the run's input from them. + * A `messages` array passed as a parameter is silently ignored, which is the worst shape a + * mistake can take. Nothing failed. The addressed Bot ran, against an empty conversation, and + * answered "how can I help?" to a question it had never been shown, in a transcript that + * displayed the question directly above the answer. + */ /* * The conversation that ASKED, not the one it is answering in. The addressed Bot is joining * something already in progress and has to have read it; its own conversation is new and * empty, and reading that would tell it nothing. */ - ...conversationOnly( + const prior = conversationOnly( await history({ threadId: work.threadId, actorId: work.actorId }), - ), - { id: `handoff-${runId}`, role: "user", content: message }, - ]; - agent.threadId = where.threadId; - // The platform's own message type rather than AG-UI's, which is what `history` returns: the - // two agree where it matters, and converting between them is a place to lose a message. - agent.setMessages(asked as Parameters[0]); - /* - * Renewed while the addressed Bot works, because the lock expires on its own. A run is minutes - * and the platform's window is short; a lock that lapses mid-answer lets a second run into the - * conversation, which is the thing it exists to prevent. - */ - const heartbeat = setInterval(() => { - void lock.renew({ threadId: where.threadId, runId }).catch(() => {}); - }, LOCK_RENEW_EVERY_MS); + ); + const asked = [ + /* + * A backwards hop gets the tail, not the whole. Its task already carries everything it has + * to say — the answer, or the failure — and the person is waiting through this run's + * time-to-first-token: a long channel read in full would make relaying an answer slower + * the longer the conversation that wanted it. A few recent turns keep the voice and the + * pronouns right; the rest is weight. + */ + ...(work.answerIn ? prior.slice(-RELAY_CONTEXT_MESSAGES) : prior), + { id: `handoff-${runId}`, role: "user", content: message }, + ]; + agent.threadId = where.threadId; + // The platform's own message type rather than AG-UI's, which is what `history` returns: the + // two agree where it matters, and converting between them is a place to lose a message. + agent.setMessages(asked as Parameters[0]); + /* + * Renewed while the addressed Bot works, because the lock expires on its own. A run is minutes + * and the platform's window is short; a lock that lapses mid-answer lets a second run into the + * conversation, which is the thing it exists to prevent. + */ + const heartbeat = setInterval(() => { + void lock.renew({ threadId: where.threadId, runId }).catch(() => {}); + }, LOCK_RENEW_EVERY_MS); - try { - await settled( - runner.run({ - threadId: where.threadId, - agent, - /* - * What the conversation KEEPS, which is not what the model was sent. - * - * The runner persists whatever it is given here, and given nothing it persists the whole - * prompt: the asking conversation's history repeated into a second conversation, and a - * paragraph of instructions to a model sitting in a bubble that looks like something the - * person typed. What belongs in a transcript is the one line saying why this Bot spoke. - */ - persistedInputMessages: shown - ? [{ id: `handoff-${runId}`, role: "user", content: shown }] - : [], - /* - * NOTHING IS PASSED FOR THE CONNECTION, and that is load-bearing. - * - * The lock hands back a join token as well as a run id, and it reads like the thing to - * present here. It is not: it is what a BROWSER presents to join a conversation and - * watch it, and the runner's socket is a different connection with its own credential. - * Handing it in overrides that credential, the socket is refused, and because the runner - * treats a socket that will not connect as something to keep retrying rather than as a - * failed run, nothing is ever emitted and nothing ever completes. The hop hangs, in - * total silence, until the deadline below ends it. - * - * What makes this run legitimate is the lock itself: the gateway compares the run id on - * every event to the one the lock holds. Taking the lock is the whole of the ceremony. - */ - input: { + try { + await settled( + runner.run({ threadId: where.threadId, - runId, + agent, /* - * The same conversation the agent was given, so the run's own record of what it was - * asked agrees with what it read. + * What the conversation KEEPS, which is not what the model was sent. + * + * The runner persists whatever it is given here, and given nothing it persists the whole + * prompt: the asking conversation's history repeated into a second conversation, and a + * paragraph of instructions to a model sitting in a bubble that looks like something the + * person typed. What belongs in a transcript is the one line saying why this Bot spoke. */ - messages: asked, - tools: [], - context: [], - state: {}, + persistedInputMessages: shown + ? [{ id: `handoff-${runId}`, role: "user", content: shown }] + : [], /* - * The deployment's own statement of what this run is, carrying how deep the chain has - * gone. It is what stops the addressed Bot handing the work on for ever, and it is - * signed, so the Bot cannot edit its own depth on the way past. + * NOTHING IS PASSED FOR THE CONNECTION, and that is load-bearing. + * + * The lock hands back a join token as well as a run id, and it reads like the thing to + * present here. It is not: it is what a BROWSER presents to join a conversation and + * watch it, and the runner's socket is a different connection with its own credential. + * Handing it in overrides that credential, the socket is refused, and because the runner + * treats a socket that will not connect as something to keep retrying rather than as a + * failed run, nothing is ever emitted and nothing ever completes. The hop hangs, in + * total silence, until the deadline below ends it. + * + * What makes this run legitimate is the lock itself: the gateway compares the run id on + * every event to the one the lock holds. Taking the lock is the whole of the ceremony. */ - forwardedProps: { openbotRun: assertion }, - }, - }), - deadlineMs, - () => - `${work.toBotId} did not finish within ${Math.round(deadlineMs / 1000)}s ${ - seen.count === 0 - ? "and never reached its model" - : `after ${seen.count} events, the last ${seen.last}` - }`, - ); - /* - * Only once the run is on record. A conversation lifted to the top of somebody's list for an - * answer that then failed is worse than one that did not move: they open it and find - * nothing, and nothing says why. - */ - if (announce && where.channelId && shown) { - await announce({ - actorId: work.actorId, - channelId: where.channelId, - agentId: work.toBotId, - text: shown, - }).catch(() => { - // The turn happened. A roster that has not caught up is worth less than a hop reported - // as failed and run a second time. - }); + input: { + threadId: where.threadId, + runId, + /* + * The same conversation the agent was given, so the run's own record of what it was + * asked agrees with what it read. + */ + messages: asked, + tools: [], + context: [], + state: {}, + /* + * The deployment's own statement of what this run is, carrying how deep the chain has + * gone. It is what stops the addressed Bot handing the work on for ever, and it is + * signed, so the Bot cannot edit its own depth on the way past. + */ + forwardedProps: { openbotRun: assertion }, + }, + }), + deadlineMs, + () => + `${work.toBotId} did not finish within ${Math.round(deadlineMs / 1000)}s ${ + seen.count === 0 + ? "and never reached its model" + : `after ${seen.count} events, the last ${seen.last}` + }`, + ); + /* + * Only once the run is on record, and only when it said something: a conversation lifted to + * the top of somebody's list for a turn that failed, or said nothing, is a conversation + * they open to find nothing new. + */ + if (announce && said.length > 0) { + await announce({ + actorId: work.actorId, + threadId: where.threadId, + agentId: work.toBotId, + text: said.join("\n\n"), + }).catch(() => { + // The turn happened. A roster that has not caught up is worth less than a hop reported + // as failed and run a second time. + }); + } + } finally { + clearInterval(heartbeat); + /* + * Given back whatever happened. Left held, the conversation is unusable by anybody until the + * lock expires: the person cannot ask a follow-up and the next hop is refused, which turns + * one failed delivery into a conversation that has stopped working. + */ + /* + * The conversation the lock was taken on, which is the one being answered in and NOT the one + * that asked. Releasing the asking conversation's lock instead leaves this one held until it + * lapses: the person cannot type in it and the next hop to the same Bot is refused, while a + * lock somebody else may be holding on the asking side is dropped from under them. + */ + await lock + .release({ threadId: where.threadId, runId }) + .catch(() => {}); } } finally { - clearInterval(heartbeat); - /* - * Given back whatever happened. Left held, the conversation is unusable by anybody until the - * lock expires: the person cannot ask a follow-up and the next hop is refused, which turns - * one failed delivery into a conversation that has stopped working. - */ - /* - * The conversation the lock was taken on, which is the one being answered in and NOT the one - * that asked. Releasing the asking conversation's lock instead leaves this one held until it - * lapses: the person cannot type in it and the next hop to the same Bot is refused, while a - * lock somebody else may be holding on the asking side is dropped from under them. - */ - await lock.release({ threadId: where.threadId, runId }).catch(() => {}); + if (lightsAskingChannel) { + await setBusy?.({ threadId: work.threadId, busy: false }).catch( + () => {}, + ); + } } + + /* + * What came back, for the runner to relay. Null when the turn produced no words at all — + * a run that only called tools — which the runner treats as nothing worth carrying back. + */ + return { answer: said.length > 0 ? said.join("\n\n") : null }; }, }; } @@ -380,6 +452,14 @@ function conversationOnly(messages: readonly unknown[]): readonly unknown[] { }); } +/** + * How much of the asking conversation a backwards hop reads. + * + * Six messages is about three exchanges: enough for the relaying Bot to keep its footing in the + * conversation it is speaking into, and small enough that the read never grows with the channel. + */ +const RELAY_CONTEXT_MESSAGES = 6; + /** * How often the conversation's lock is refreshed while a Bot is working. * diff --git a/server/src/agents/handoff-runner.ts b/server/src/agents/handoff-runner.ts index 1950e322..5b7ab8db 100644 --- a/server/src/agents/handoff-runner.ts +++ b/server/src/agents/handoff-runner.ts @@ -46,6 +46,10 @@ export type HandoffDelivery = { * Rejecting means the hop did not happen and is worth another go. Resolving means it did, whatever * the Bot said: a Bot that answers "I could not find that" has answered, and retrying would ask it * the same question again and bill for the same non-answer. + * + * Resolves with what the Bot said, because its turn runs in a scratch thread nobody is shown: + * the words it comes back with exist for the relay or not at all. Null means a turn of nothing + * but tool calls, which is a turn that happened and nothing worth carrying back. */ deliver: (input: { work: HandoffWork; @@ -67,7 +71,7 @@ export type HandoffDelivery = { shown?: string; /** The signed statement of the run it is starting, carrying its depth. */ assertion: string; - }) => Promise; + }) => Promise<{ answer: string | null }>; }; export type HandoffRunReport = { @@ -139,6 +143,37 @@ export function createHandoffRunner(options: { * Marked with `answerIn`, which is also what stops this recursing: a notice that fails is not * itself worth a notice, and the check above skips any hop that carries one. */ + /** + * Put the answer in front of the person, the same way a failure is: by running the Bot that + * asked, in the conversation they are watching. + * + * THE ADDRESSED BOT NEVER SPEAKS THERE — the platform gives a thread exactly one agent — so its + * words come home in the asking Bot's voice, attributed. The same `answerIn` marker that stops a + * notice recursing stops a relay relaying: a hop that carries one enqueues nothing when it lands. + * + * The answer is clipped rather than trusted to be a paragraph. It rides inside the prompt of the + * relaying run, and a Bot that came back with a report the length of a book would otherwise spend + * the relay's whole context window repeating it. + */ + const relay = (work: HandoffWork, key: string, answer: string) => + queue.offer({ + kind: HANDOFF_KIND, + // Outside the run's fan-out prefix and keyed on the hop, for the same two reasons as the + // notice below: a relay is not a Bot this run asked for, and one run may legally ask the + // same Bot two different things. + key: `relay:${key}`, + payload: { + fromBotId: work.toBotId, + toBotId: work.fromBotId, + actorId: work.actorId, + threadId: work.threadId, + runId: work.runId, + depth: work.depth, + answerIn: work.threadId, + task: `You asked ${work.toName ?? work.toBotId} to help with this: ${work.task}\n\nIt answered:\n\n${clip(answer)}\n\nGive the person the outcome. Keep what matters, drop the pleasantries, and say it came from ${work.toName ?? work.toBotId}.`, + } as unknown as Record, + }); + const tell = (work: HandoffWork, key: string, reason: string) => queue.offer({ kind: HANDOFF_KIND, @@ -309,7 +344,7 @@ export function createHandoffRunner(options: { try { const shown = summarise(work); - await delivery.deliver({ + const { answer } = await delivery.deliver({ work, message: attribute(work), ...(shown ? { shown } : {}), @@ -349,6 +384,22 @@ export function createHandoffRunner(options: { continue; } report.delivered.push(work.toBotId); + /* + * The answer goes home through the queue, like the turn that produced it: durable, so a + * pod dying between the turn and the relay loses the relay to a retry rather than for + * ever. Only for a forward hop with words to carry — a relay of a relay is the loop the + * `answerIn` check exists to stop, and a wordless turn has nothing to say. + */ + if (!work.answerIn && answer) { + await relay(work, item.key, answer).catch((failure) => { + // The turn happened and is on record; a relay that cannot be queued must not undo + // that by failing the hop into a retry and a second turn. + console.warn( + "Could not queue the relay for a delivered hop.", + failure, + ); + }); + } await recordAuditEvent(auditStore, { eventType: "agent.handoff_delivered", targetType: "agent", @@ -424,6 +475,21 @@ export function createHandoffRunner(options: { }; } +/** + * How much of an answer one relay will carry. + * + * Generous, because with the answer living nowhere a person is shown, what the relay drops is gone: + * the scratch thread that holds the rest is never mapped to a channel. The cap exists for the Bot + * that comes back with a book — an answer that size swamps the relaying run's prompt, and the + * asking Bot was told what a good answer looks like precisely so this stays a paragraph. + */ +const RELAY_ANSWER_LIMIT = 12_000; + +function clip(answer: string): string { + if (answer.length <= RELAY_ANSWER_LIMIT) return answer; + return `${answer.slice(0, RELAY_ANSWER_LIMIT)}\n\n[…the answer was cut here for length]`; +} + /** * The same failure, in words that can be said out loud. * diff --git a/server/src/agents/handoff-tool.ts b/server/src/agents/handoff-tool.ts index 1679a40a..52452fa4 100644 --- a/server/src/agents/handoff-tool.ts +++ b/server/src/agents/handoff-tool.ts @@ -119,7 +119,7 @@ export function handoffTool(options: { * way, and the model is owed something it can say out loud. */ return outcome.ok - ? `${HANDED_OVER}${outcome.toName}. It will answer in its own conversation with this person, so tell them you have asked it and what for, and do not answer on its behalf.` + ? `${HANDED_OVER}${outcome.toName}. Its answer will be relayed back into this conversation when it finishes, so tell the person you have asked it and what for, and do not answer on its behalf.` : outcome.refusal; }, }; diff --git a/server/src/app.ts b/server/src/app.ts index 20561446..36617003 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -36,6 +36,7 @@ import { createComputerRoutes } from "./computer/routes"; import { configuredAuthProviders, type DeploymentConfig } from "./config"; import type { CredentialAdminService, CredentialInput } from "./credentials"; import { createIntelligenceClient } from "./intelligence-client"; +import type { OnboardingStore } from "./people/onboarding"; import type { PeopleStore } from "./people/store"; import { createPluginRoutes } from "./plugins/routes"; import type { PluginStore } from "./plugins/store"; @@ -190,6 +191,17 @@ export function createApp( * has no door for this at all, not a locked one. */ routineStore?: RoutineStore, + /** + * Where each person is in first-run onboarding. + * + * Appended last, like everything above it: these are positional, so inserting one anywhere else + * silently shifts every existing call site's arguments by one. + * + * Absent leaves /api/me reporting no onboarding to track, which is the correct degraded + * behaviour: a deployment that cannot read the status must not lock everybody behind a gate + * nothing can finish. + */ + onboardingStore?: OnboardingStore, ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -276,9 +288,52 @@ export function createApp( ? createRequireUser(auth, roleRepository) : authenticationUnavailable; - app.get("/api/me", requireUser, (context) => - context.json({ user: context.var.actor }), + app.get("/api/me", requireUser, async (context) => + context.json({ + user: { + ...context.var.actor, + /* + * Read here rather than in the guard, so only this route pays the extra query. Null means + * this deployment does not track onboarding, which the app reads as nothing to finish; + * a not-yet-completed status is what sends it to /onboarding. + */ + onboarding: onboardingStore + ? await onboardingStore.status(context.var.actor.id) + : null, + }, + }), ); + app.post("/api/me/onboarding", requireUser, async (context) => { + if (!onboardingStore) { + return context.json({ error: "Onboarding is not available." }, 503); + } + + const body = (await context.req.json().catch(() => undefined)) as + | { step?: unknown; completed?: unknown } + | undefined; + + if (body?.completed === true) { + await onboardingStore.complete(context.var.actor.id); + } else if ( + typeof body?.step === "number" && + Number.isInteger(body.step) && + body.step >= 0 && + // The column's range — the only bound the server knows, since the wizard's length is the + // app's fact rather than the deployment's. + body.step <= 2_147_483_647 + ) { + await onboardingStore.setStep(context.var.actor.id, body.step); + } else { + return context.json( + { error: "Send the step to move to, or completed: true." }, + 400, + ); + } + + return context.json({ + onboarding: await onboardingStore.status(context.var.actor.id), + }); + }); app.get("/api/admin/status", requireUser, (context) => { const denied = requireAdmin(context); return denied ?? context.json({ status: "ok" }); diff --git a/server/src/channels/events.ts b/server/src/channels/events.ts index b0bced6f..9f310bab 100644 --- a/server/src/channels/events.ts +++ b/server/src/channels/events.ts @@ -31,6 +31,14 @@ export type ChannelActivityEvent = { * hub's delivery rule does the rest: nobody else in the channel hears a pin they did not make. */ pinned?: boolean; + /** + * A turn started or ended in this channel. Absent on an ordinary activity event. + * + * Transient and message-less: it is never written to a table, only announced, so the roster can + * show a working indicator for a headless turn — a handoff hop, a relay — that no browser + * streams. A missed one costs at most a stuck-looking dot until the next real event, never data. + */ + busy?: boolean; }; type Send = (payload: string) => void; diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index afdc9ea2..58c3864e 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -182,6 +182,29 @@ export type ChannelStore = { channelId: string, activity: ChannelActivity, ): Promise; + /** + * Tell a channel's members that a turn started or ended in it, by the thread it runs in. + * + * Keyed by thread because that is all a headless turn knows. A thread that maps to no channel — + * the scratch thread a handoff answers in — resolves to nothing and signals nowhere, which is + * the point of a scratch thread. Announced, never written: `busy` is a moment, not a fact about + * the channel, and a missed one costs a dot until the next real event rather than any data. + */ + signalBusy(threadId: string, busy: boolean): Promise; + /** + * The same signal, from a person's own run, by the channel they are in. + * + * A browser knows exactly when its run starts and stops and which channel it is in, which the + * server cannot see: the runtime does not tell this deployment when a person's turn begins. So + * the browser reports it, and this checks the caller belongs to the channel before announcing — + * the membership check `signalBusy` does not need, because that one is only ever called by the + * server about work it started itself. + */ + signalChannelBusy( + actor: AgentActor, + channelId: string, + busy: boolean, + ): Promise; }; const PRIVATE_AGENT_CHANNEL_DESCRIPTION = "Private agent channel."; @@ -756,6 +779,76 @@ export function createChannelStore( { isolationLevel: "read committed" }, ); }, + + async signalBusy(threadId, busy) { + // The channel this thread is shown in, if any. A scratch thread maps to nothing, so a hop + // running there signals nowhere and the branch below returns without announcing. + const [mapped] = await database + .select({ channelId: intelligenceChannelMappings.channelId }) + .from(intelligenceChannelMappings) + .where(eq(intelligenceChannelMappings.threadId, threadId)) + .limit(1); + if (!mapped) return; + + const members = await database + .select({ userId: channelMemberships.userId }) + .from(channelMemberships) + .where(eq(channelMemberships.channelId, mapped.channelId)); + if (members.length === 0) return; + + // No table write: busy is a moment, and the roster query stays the source of truth. Just the + // announcement, carrying the members the same way recordActivity does. + const event: ChannelActivityEvent = { + channelId: mapped.channelId, + memberIds: members.map((member) => member.userId), + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + busy, + }; + await database.execute( + sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`, + ); + }, + + async signalChannelBusy(actor, channelId, busy) { + const [membership] = await database + .select({ userId: channelMemberships.userId }) + .from(channelMemberships) + .innerJoin( + channels, + and( + eq(channels.id, channelMemberships.channelId), + isNull(channels.deletedAt), + ), + ) + .where( + and( + eq(channelMemberships.channelId, channelId), + eq(channelMemberships.userId, actor.id), + ), + ); + // Not a member, no such channel, or a deleted one: the same refusal every way, so belonging + // to a channel is not something an outsider can probe for. + if (!membership) throw new ChannelNotFoundError(channelId); + + const members = await database + .select({ userId: channelMemberships.userId }) + .from(channelMemberships) + .where(eq(channelMemberships.channelId, channelId)); + + const event: ChannelActivityEvent = { + channelId, + memberIds: members.map((member) => member.userId), + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + busy, + }; + await database.execute( + sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`, + ); + }, }; return store; } @@ -983,6 +1076,26 @@ export function createChannelRoutes( } }); + routes.post("/:channelId/busy", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + busy?: unknown; + } | null; + if (typeof body?.busy !== "boolean") { + return context.json({ error: "busy must be true or false" }, 400); + } + + try { + await store.signalChannelBusy( + context.var.actor, + context.req.param("channelId"), + body.busy, + ); + return context.body(null, 204); + } catch (error) { + return mapStoreError(context, error); + } + }); + routes.put("/:channelId/pin", requireUser, async (context) => { const body = await context.req.json().catch(() => null); if (!isChannelInputObject(body)) { diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 891c53ee..8e2f9c2f 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -1014,6 +1014,15 @@ export function mountCopilotRuntime( agentFetch?: AgentFetch, /** How a run gets its tool for handing work on. Absent means no Bot is offered one. */ handoffForActor?: (actorId: string) => HandoffForRun, + /** + * Told when a run starts and ends on a thread, so a channel can show it is working. + * + * The universal seam: every run the runtime processes — a person's own turn, a headless hop — + * takes and gives back the thread lock, and it does so on the server, so a person who sends a + * message and navigates away still lights the channel they left. A side effect only: it is never + * awaited in the lock path and a failure in it never touches whether the lock was taken. + */ + onRunBusy?: (input: { threadId: string; busy: boolean }) => void, ) { const { intelligence } = config.runtime; @@ -1145,6 +1154,11 @@ export function mountCopilotRuntime( }) => { try { const held = await intelligenceClient.ɵacquireThreadLock(input); + // A run started on this thread. Side effect only, never awaited: a channel showing it is + // working is worth nothing next to the lock the run depends on. + try { + onRunBusy?.({ threadId: input.threadId, busy: true }); + } catch {} /* * The run id only. The lock also hands back a join token, which is what a browser presents * to watch the conversation; the runner's socket has its own credential and passing this @@ -1181,6 +1195,11 @@ export function mountCopilotRuntime( }); }, release: async (input: { threadId: string; runId: string }) => { + // The run on this thread is over. Cleared here rather than trusting a browser: the run may + // have outlived the tab that started it, and this is where the platform is told it ended. + try { + onRunBusy?.({ threadId: input.threadId, busy: false }); + } catch {} await intelligenceClient.ɵcleanupThreadLock(input); }, }, diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts index f8869b26..44d5753a 100644 --- a/server/src/db/schema/core.ts +++ b/server/src/db/schema/core.ts @@ -2,6 +2,7 @@ import { sql } from "drizzle-orm"; import { boolean, index, + integer, pgEnum, pgTable, primaryKey, @@ -63,6 +64,16 @@ export const users = pgTable("users", { * list for everybody. */ groups: text("groups").array().notNull().default([]), + /** + * Where this person is in first-run onboarding. + * + * The step is where the wizard resumes if they leave halfway; the null completion timestamp is + * what gates the app into /onboarding. Set once — finishing again keeps the first timestamp. + */ + onboardingStep: integer("onboarding_step").notNull().default(0), + onboardingCompletedAt: timestamp("onboarding_completed_at", { + withTimezone: true, + }), createdAt: createdAt(), updatedAt: updatedAt(), }); diff --git a/server/src/index.ts b/server/src/index.ts index a3b18ef6..c3e3c1c6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,11 +4,12 @@ import { IntelligenceAgentRunner, } from "@copilotkit/runtime/v2"; import { serve } from "bun"; +import { eq } from "drizzle-orm"; import { COMPUTER_GUIDANCE } from "../../shared/bot-prompt"; import { mintRunAssertion, readRunAssertion } from "./agents/callback-token"; import { createAgentFetch } from "./agents/endpoint"; import { askTheirOwnPerson, escalationTool } from "./agents/escalation"; -import { createHandoffDesk } from "./agents/handoff"; +import { createHandoffDesk, HANDOFF_KIND } from "./agents/handoff"; import { createHandoffDelivery } from "./agents/handoff-delivery"; import { createHandoffRunner } from "./agents/handoff-runner"; import { handoffTool } from "./agents/handoff-tool"; @@ -59,6 +60,8 @@ import { resolveModelApiKey, } from "./credentials"; import { createDatabase } from "./db/client"; +import { intelligenceChannelMappings } from "./db/schema"; +import { createOnboardingStore } from "./people/onboarding"; import { createPeopleStore } from "./people/store"; import { useRoutineTools } from "./plugins/builtin-routines"; import { redirectUriFor } from "./plugins/oauth"; @@ -75,7 +78,7 @@ import { synchronizeTenantPackage, } from "./tenant-package"; import { repeatAfterEach } from "./work/loop"; -import { createWorkQueue } from "./work/queue"; +import { createWorkQueue, startWorkOfferedListener } from "./work/queue"; /** * Who is asking, for a CopilotKit request. @@ -804,6 +807,11 @@ const copilotRuntime = mountCopilotRuntime( }); return passing ? [passing, asking] : [asking]; }, + // A run started or ended on a thread; light the channel it belongs to. Fire-and-forget, keyed by + // thread, and a scratch thread maps to no channel and signals nowhere. + (input) => { + void channelStore.signalBusy(input.threadId, input.busy).catch(() => {}); + }, ); /** @@ -825,24 +833,6 @@ const copilotRuntime = mountCopilotRuntime( * per day, for a feature it had turned off. */ if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) { - /** - * The person a delivery acts as, with a failure a person can be told about. - * - * `actorFor` throws when a role cannot be established — a revoked role, or a database that - * blinked. Thrown from inside a delivery that message becomes the reason on a failed hop, and the - * reason is paraphrased to somebody by the Bot that asked: "A routine requires an authorized - * owner." is not a sentence to put in front of a person who asked about a refund policy. - */ - const theirActor = async (userId: string) => { - const actor = await actorFor(userId).catch(() => null); - if (!actor) { - throw new Error( - "who this is for could not be confirmed, so the answer had nowhere to go", - ); - } - return actor; - }; - const runner = createHandoffRunner({ queue: createWorkQueue(database), owner: `handoff/${process.env.HOSTNAME ?? randomUUID().slice(0, 8)}`, @@ -880,32 +870,38 @@ if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) { history: copilotRuntime.history, lock: copilotRuntime.threadLock, /* - * A conversation of the addressed Bot's own, with the same person. + * A scratch thread of the addressed Bot's own, one per hop. * * An Intelligence thread has exactly one agent, so a second Bot cannot answer inside the first - * Bot's conversation however it asks. Rather than pretend otherwise, the answer lands where - * that Bot can speak and the conversation that asked says where it went. + * Bot's conversation however it asks. Its turn runs here instead, unmapped to any channel, and + * what it said comes back to the conversation that asked through the relay — in the asking + * Bot's voice, which is the only voice that thread admits. Minted with the deployment's own + * identity, like every thread this deployment starts. */ - answerIn: async (input) => { - // The conversation this person already has with that Bot, made only if they have not had - // one. See ChannelStore.direct: a hop is retried, and creating here left an empty channel - // behind for every attempt. - // The person's own role, for the same reason the desk resolves it: an administrator sees Bots - // a user does not, and a conversation with one of those is still theirs. - const channel = await channelStore.direct( - await theirActor(input.actorId), - input.botId, - ); - return { threadId: channel.threadId, channelId: channel.id }; + mintThreadId: () => threadIdentity.mint(), + /* + * The roster, told that a relayed answer landed. The delivery knows only the thread it ran + * in; this resolves which channel shows that thread — a scratch thread maps to nothing and + * announces nowhere, which is the point of a scratch thread. + */ + announce: async (input) => { + const [mapped] = await database + .select({ channelId: intelligenceChannelMappings.channelId }) + .from(intelligenceChannelMappings) + .where(eq(intelligenceChannelMappings.threadId, input.threadId)) + .limit(1); + if (!mapped) return; + const actor = await actorFor(input.actorId).catch(() => null); + if (!actor) return; + await channelStore.recordActivity(actor, mapped.channelId, { + text: input.text, + agentId: input.agentId, + at: new Date(), + }); }, - // The roster is written by whoever finished a run, and for a hop that is this server rather - // than a browser. See ChannelStore.recordActivity. - announce: async (input) => - channelStore.recordActivity( - await theirActor(input.actorId), - input.channelId, - { text: input.text, agentId: input.agentId, at: new Date() }, - ), + // The asking conversation shown as working while a hop runs in it. Keyed by thread, resolved + // to its channel by the store; a scratch thread maps to none and signals nowhere. + setBusy: (input) => channelStore.signalBusy(input.threadId, input.busy), newRunId: () => randomUUID(), // The same address and the same token the runtime uses. Assembling either from configuration // produced a runner every join was refused for, because the thread's active run is a lock the @@ -932,12 +928,41 @@ if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) { }; /* - * ONE SWEEP AT A TIME ON THIS REPLICA. See repeatAfterEach: an interval would start another sweep - * every two seconds while a five-minute delivery runs, each claiming a different batch, and this - * replica's concurrent agent runs would grow with the backlog rather than stopping at the limit - * it was asked for. + * ONE SWEEP AT A TIME ON THIS REPLICA, from both callers below. A sweep poked while one is + * running is remembered rather than started, and runs once the current one ends — a wake-up + * that arrived mid-sweep may be for a hop the running sweep's claim already missed. */ - repeatAfterEach(sweep, 2_000); + let sweeping = false; + let sweepAgain = false; + const kick = async () => { + if (sweeping) { + sweepAgain = true; + return; + } + sweeping = true; + try { + do { + sweepAgain = false; + await sweep(); + } while (sweepAgain); + } finally { + sweeping = false; + } + }; + + /* + * Woken by the queue itself, from any replica: a person is waiting through every hop, and the + * poll below would spend up to two seconds per leg doing nothing. The poll stays as the + * backstop — a notification is a latency optimisation, and one lost in transit costs one + * interval, never the work. See repeatAfterEach for why an interval must not be used: an + * interval would start another sweep every two seconds while a five-minute delivery runs, each + * claiming a different batch, and this replica's concurrent agent runs would grow with the + * backlog rather than stopping at the limit it was asked for. + */ + await startWorkOfferedListener(config.databaseUrl, (kind) => { + if (kind === HANDOFF_KIND) void kick(); + }); + repeatAfterEach(kick, 2_000); } /* @@ -1026,6 +1051,8 @@ const app = createApp( routineRunner, // A person's own standing instructions: the list, and a switch to stop one. routineStore, + // Where each person is in first-run onboarding, read by /api/me and written by the wizard. + createOnboardingStore(database), ); /** diff --git a/server/src/people/onboarding.ts b/server/src/people/onboarding.ts new file mode 100644 index 00000000..90e2a571 --- /dev/null +++ b/server/src/people/onboarding.ts @@ -0,0 +1,64 @@ +import { eq, sql } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { users } from "../db/schema"; + +/** + * Where one person is in first-run onboarding. + * + * A null `completedAt` is what gates the app into /onboarding; `step` is where the wizard resumes + * if they left halfway through. + */ +export type OnboardingStatus = { + step: number; + completedAt: string | null; +}; + +export type OnboardingStore = { + status: (userId: string) => Promise; + setStep: (userId: string, step: number) => Promise; + complete: (userId: string) => Promise; +}; + +export function createOnboardingStore(database: Database): OnboardingStore { + return { + async status(userId) { + const [row] = await database + .select({ + step: users.onboardingStep, + completedAt: users.onboardingCompletedAt, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + /* + * No row reads as not started rather than as an error. It should not happen — a session + * cannot outlive its users row (the foreign key cascades) and the single-user actor's row is + * seeded at startup — but a guard that throws here would take /api/me down with it. + */ + return { + step: row?.step ?? 0, + completedAt: row?.completedAt ? row.completedAt.toISOString() : null, + }; + }, + + async setStep(userId, step) { + await database + .update(users) + .set({ onboardingStep: step, updatedAt: new Date() }) + .where(eq(users.id, userId)); + }, + + async complete(userId) { + await database + .update(users) + .set({ + // Idempotent: finishing again keeps the first timestamp, so "when did they onboard" + // stays one answer. + onboardingCompletedAt: sql`coalesce(${users.onboardingCompletedAt}, now())`, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)); + }, + }; +} diff --git a/server/src/work/queue.ts b/server/src/work/queue.ts index 82249791..8676a9c1 100644 --- a/server/src/work/queue.ts +++ b/server/src/work/queue.ts @@ -16,9 +16,53 @@ * from under the first. Both then ran it. Every time this file names a moment it names it in SQL. */ import { and, eq, gte, isNull, like, lt, or, sql } from "drizzle-orm"; +import postgres from "postgres"; import type { Database } from "../db/client"; import { workItems } from "../db/schema"; +/** + * Said aloud when work is queued, so a sweep can start now instead of at its next poll. + * + * The payload is the kind, and nothing more: a listener decides whether it sweeps that kind at + * all, and the queue stays the truth either way. A notification is a latency optimisation, never + * a delivery mechanism — one lost in transit costs up to one poll interval, not the work. + */ +export const WORK_OFFERED_TOPIC = "openbot_work_offered"; + +/** Inside the offering transaction where there is one, so it fires on commit and never before. */ +const announceOffered = (db: Pick, kind: string) => + db.execute(sql`select pg_notify(${WORK_OFFERED_TOPIC}, ${kind})`); + +export type WorkOfferedListener = { stop: () => Promise }; + +/** + * Hear work being offered, from 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. + */ +export async function startWorkOfferedListener( + databaseUrl: string, + onOffered: (kind: string) => void, +): Promise { + const connection = postgres(databaseUrl, { max: 1 }); + + await connection.listen(WORK_OFFERED_TOPIC, (payload) => { + try { + onOffered(payload); + } catch { + // A listener's mistake is not a reason to tear down the subscription: the poll behind it is + // still correct, and the next sweep picks up whatever this wake-up would have. + } + }); + + return { + stop: async () => { + await connection.end(); + }, + }; +} + export type WorkItem = { kind: string; key: string; @@ -178,6 +222,7 @@ export function createWorkQueue(database: Database): WorkQueue { if (!atMost) { const [written] = await write(database); + if (written) await announceOffered(database, kind); return written ? "queued" : "already"; } @@ -215,6 +260,7 @@ export function createWorkQueue(database: Database): WorkQueue { if (already.length > 0) return "already"; if ((row?.total ?? 0) >= atMost.max) return "refused"; const [written] = await write(transaction as unknown as Database); + if (written) await announceOffered(transaction, kind); return written ? "queued" : "already"; }); }, diff --git a/server/tests/agent-handoff-delivery.test.ts b/server/tests/agent-handoff-delivery.test.ts index 7252c6d7..6f364e28 100644 --- a/server/tests/agent-handoff-delivery.test.ts +++ b/server/tests/agent-handoff-delivery.test.ts @@ -64,7 +64,7 @@ function delivery( agentFor: async () => agent, history: async () => options.history ?? PRIOR, newRunId: () => "run-2", - answerIn: async () => ({ threadId: "answer-thread" }), + mintThreadId: () => "scratch-thread", lock: { acquire: async () => { lockCalls.push("acquire"); @@ -133,7 +133,7 @@ describe("turning a hop into a turn", () => { openbotRun: "signed-assertion", }); // The addressed Bot's own conversation, because a thread has exactly one agent. - expect(requests[0]?.threadId).toBe("answer-thread"); + expect(requests[0]?.threadId).toBe("scratch-thread"); }); /* @@ -229,13 +229,14 @@ describe("holding the conversation while a Bot answers", () => { }); /** - * Where an answer can land, which the platform decides rather than this code. + * Where the turn can run, which the platform decides rather than this code. * * An Intelligence thread is owned by exactly one agent. A second Bot answering inside the first - * Bot's conversation is refused however it asks, so the answer goes where that Bot can speak. + * Bot's conversation is refused however it asks, so its turn runs in a scratch thread of its own + * and the words come home through the relay. */ -describe("which conversation the answer lands in", () => { - test("the addressed Bot's own, not the one that asked", async () => { +describe("which conversation the turn runs in", () => { + test("a scratch thread of the addressed Bot's own, not the one that asked", async () => { const { delivery: deliver, requests } = delivery(FINISHED); await deliver.deliver({ @@ -245,8 +246,8 @@ describe("which conversation the answer lands in", () => { assertion: "s", }); - expect(requests[0]?.threadId).toBe("answer-thread"); - expect(requests[0]?.input.threadId).toBe("answer-thread"); + expect(requests[0]?.threadId).toBe("scratch-thread"); + expect(requests[0]?.input.threadId).toBe("scratch-thread"); }); test("but it reads the conversation that asked", async () => { @@ -330,7 +331,7 @@ describe("a delivery that never finishes", () => { agentFor: async () => stubAgent(), history: async () => PRIOR, newRunId: () => "run-2", - answerIn: async () => ({ threadId: "answer-thread" }), + mintThreadId: () => "scratch-thread", lock: { acquire: async () => ({ runId: "platform-run" }), renew: async () => {}, @@ -360,7 +361,7 @@ describe("a delivery that never finishes", () => { }); // Not `thread-1`, which is the conversation that ASKED and whose lock this run never held. - expect(released).toEqual(["answer-thread"]); + expect(released).toEqual(["scratch-thread"]); }); }); @@ -427,11 +428,239 @@ describe("what the addressed Bot is actually given", () => { expect(given.at(-1)).toMatchObject({ role: "user", content: "the ask" }); // And it runs in its own conversation, which the agent also carries. expect((agent as unknown as { threadId: string }).threadId).toBe( - "answer-thread", + "scratch-thread", ); }); }); +/** + * Lighting the asking channel while a hop runs. + * + * A forward hop runs in a scratch thread nobody watches, so it signals the asking channel itself. A + * backwards hop runs in the asking thread, whose lock the runtime already watches, so it must not + * signal again — that would double the indicator on and off. + */ +describe("the working indicator", () => { + function withBusy() { + const busy: Array<{ threadId: string; busy: boolean }> = []; + const deliver = createHandoffDelivery({ + agentFor: async () => stubAgent(), + history: async () => PRIOR, + newRunId: () => "run-2", + mintThreadId: () => "scratch-thread", + setBusy: async (input) => { + busy.push(input); + }, + lock: { + acquire: async () => ({ runId: "platform-run" }), + renew: async () => {}, + release: async () => {}, + }, + runner: { + run: () => + new Observable((subscriber) => { + for (const event of FINISHED) subscriber.next(event); + subscriber.complete(); + }), + }, + }); + return { busy, deliver }; + } + + test("a forward hop lights the asking channel, on then off", async () => { + const { busy, deliver } = withBusy(); + + await deliver.deliver({ + work: WORK, + message: "m", + shown: "s", + assertion: "s", + }); + + // Keyed on the asking thread, not the scratch thread the run happens in. + expect(busy).toEqual([ + { threadId: "thread-1", busy: true }, + { threadId: "thread-1", busy: false }, + ]); + }); + + test("a backwards hop leaves the indicator to the runtime lock", async () => { + const { busy, deliver } = withBusy(); + + await deliver.deliver({ + work: { ...WORK, answerIn: "thread-1" }, + message: "m", + assertion: "s", + }); + + expect(busy).toEqual([]); + }); +}); + +/** + * What the Bot said, gathered from the stream. + * + * The runner publishes the turn to the platform rather than back through the observable, and the + * scratch thread it lands in is never shown to anybody: the events going past are the one chance to + * hear the answer, and the resolved value is the only copy the relay — and therefore the person — + * will ever get. + */ +describe("what comes back for the relay", () => { + /** An agent whose run emits the given events through the delivery's own onEvent hook. */ + function talkingAgent( + events: Array<{ type: string; delta?: string }>, + ): AbstractAgent { + const agent = { + threadId: "", + messages: [] as unknown[], + setMessages(messages: unknown[]) { + agent.messages = messages; + }, + runAgent: ( + _input: unknown, + config?: { onEvent?: (emitted: unknown) => void }, + ) => { + for (const event of events) config?.onEvent?.({ event }); + return Promise.resolve(); + }, + }; + return agent as unknown as AbstractAgent; + } + + /** A runner that drives the agent the way the real one does, then completes. */ + function throughTheAgent(agent: AbstractAgent) { + return createHandoffDelivery({ + agentFor: async () => agent, + history: async () => PRIOR, + newRunId: () => "run-2", + mintThreadId: () => "scratch-thread", + lock: { + acquire: async () => ({ runId: "platform-run" }), + renew: async () => {}, + release: async () => {}, + }, + runner: { + run: (request) => { + void ( + request.agent as unknown as { + runAgent: (input: unknown, config?: unknown) => Promise; + } + ).runAgent({}, {}); + return new Observable((subscriber) => { + for (const event of FINISHED) subscriber.next(event); + subscriber.complete(); + }); + }, + }, + }); + } + + test("the words the Bot said resolve out of the delivery", async () => { + const agent = talkingAgent([ + { type: "TEXT_MESSAGE_START" }, + { type: "TEXT_MESSAGE_CONTENT", delta: "The outage " }, + { type: "TEXT_MESSAGE_CONTENT", delta: "was Tuesday." }, + { type: "TEXT_MESSAGE_END" }, + ]); + + const { answer } = await throughTheAgent(agent).deliver({ + work: WORK, + message: "m", + shown: "s", + assertion: "s", + }); + + expect(answer).toBe("The outage was Tuesday."); + }); + + test("a turn that said several things carries all of them", async () => { + const agent = talkingAgent([ + { type: "TEXT_MESSAGE_START" }, + { type: "TEXT_MESSAGE_CONTENT", delta: "Looking now." }, + { type: "TEXT_MESSAGE_END" }, + { type: "TEXT_MESSAGE_START" }, + { type: "TEXT_MESSAGE_CONTENT", delta: "Found it: Tuesday." }, + { type: "TEXT_MESSAGE_END" }, + ]); + + const { answer } = await throughTheAgent(agent).deliver({ + work: WORK, + message: "m", + shown: "s", + assertion: "s", + }); + + expect(answer).toBe("Looking now.\n\nFound it: Tuesday."); + }); + + test("a turn that spoke announces itself, where the wiring can find a channel", async () => { + const announced: Array<{ + threadId: string; + agentId: string; + text: string; + }> = []; + const agent = talkingAgent([ + { type: "TEXT_MESSAGE_START" }, + { type: "TEXT_MESSAGE_CONTENT", delta: "Tuesday." }, + { type: "TEXT_MESSAGE_END" }, + ]); + const deliver = createHandoffDelivery({ + agentFor: async () => agent, + history: async () => PRIOR, + newRunId: () => "run-2", + mintThreadId: () => "scratch-thread", + announce: async ({ threadId, agentId, text }) => { + announced.push({ threadId, agentId, text }); + }, + lock: { + acquire: async () => ({ runId: "platform-run" }), + renew: async () => {}, + release: async () => {}, + }, + runner: { + run: (request) => { + void ( + request.agent as unknown as { + runAgent: (input: unknown, config?: unknown) => Promise; + } + ).runAgent({}, {}); + return new Observable((subscriber) => { + for (const event of FINISHED) subscriber.next(event); + subscriber.complete(); + }); + }, + }, + }); + + // A backwards hop: the relay, landing where the person is watching. + await deliver.deliver({ + work: { ...WORK, answerIn: "thread-1" }, + message: "m", + assertion: "s", + }); + + expect(announced).toEqual([ + { threadId: "thread-1", agentId: WORK.toBotId, text: "Tuesday." }, + ]); + }); + + test("a turn of nothing but tool calls has nothing to carry", async () => { + const agent = talkingAgent([ + { type: "TOOL_CALL_START" }, + { type: "TOOL_CALL_END" }, + ]); + + const { answer } = await throughTheAgent(agent).deliver({ + work: WORK, + message: "m", + shown: "s", + assertion: "s", + }); + + expect(answer).toBeNull(); + }); +}); + /** * A message is not always a string. * diff --git a/server/tests/agent-handoff-endtoend.integration.test.ts b/server/tests/agent-handoff-endtoend.integration.test.ts index f4cd49f5..86584ef7 100644 --- a/server/tests/agent-handoff-endtoend.integration.test.ts +++ b/server/tests/agent-handoff-endtoend.integration.test.ts @@ -146,6 +146,7 @@ describe("a hop, from the tool call to the delivery", () => { delivery: { deliver: async ({ work, message }) => { delivered.push({ work, message }); + return { answer: null }; }, }, }); @@ -195,6 +196,7 @@ describe("a hop, from the tool call to the delivery", () => { delivery: { deliver: async ({ work }) => { seen.push(work.toBotId); + return { answer: null }; }, }, }), @@ -231,7 +233,7 @@ describe("a hop, from the tool call to the delivery", () => { owner: `replica-${suite}`, sign: () => "signed", auditStore, - delivery: { deliver: async () => {} }, + delivery: { deliver: async () => ({ answer: null }) }, }); await runner.sweep(); diff --git a/server/tests/agent-handoff-runner.integration.test.ts b/server/tests/agent-handoff-runner.integration.test.ts index 91a76045..5abad448 100644 --- a/server/tests/agent-handoff-runner.integration.test.ts +++ b/server/tests/agent-handoff-runner.integration.test.ts @@ -85,6 +85,7 @@ describe("a batch of hops and a lease that can run out", () => { deliver: async ({ work }) => { ran.push(`a:${work.toBotId}`); if (work.toBotId === "bot-1") await held.promise; + return { answer: null }; }, }, }); @@ -94,6 +95,7 @@ describe("a batch of hops and a lease that can run out", () => { delivery: { deliver: async ({ work }) => { ran.push(`b:${work.toBotId}`); + return { answer: null }; }, }, }); @@ -140,6 +142,7 @@ describe("a batch of hops and a lease that can run out", () => { deliver: async ({ work }) => { ran.push(work.toBotId); if (work.toBotId === "bot-1") await held.promise; + return { answer: null }; }, }, }); diff --git a/server/tests/agent-handoff-runner.test.ts b/server/tests/agent-handoff-runner.test.ts index fa7c726e..61c5c56e 100644 --- a/server/tests/agent-handoff-runner.test.ts +++ b/server/tests/agent-handoff-runner.test.ts @@ -31,6 +31,8 @@ function runner(options?: { message: string; shown?: string; }) => Promise; + /** What the delivery says the Bot answered. Null — nothing worth relaying — unless a test cares. */ + answer?: string | null; }) { const calls: Array<{ verb: string; key: string; owner?: string }> = []; const events: string[] = []; @@ -92,6 +94,7 @@ function runner(options?: { deliver: async ({ work, message, shown, assertion }) => { delivered.push({ message, assertion }); await options?.deliver?.({ work, message, shown }); + return { answer: options?.answer ?? null }; }, }, }), @@ -342,6 +345,89 @@ describe("a hop that failed for good", () => { }); }); +/** + * The answer coming home. + * + * The addressed Bot's turn runs in a scratch thread nobody is shown, so its words reach the person + * one way: a backwards hop that runs the asking Bot, in the conversation being watched, with the + * answer in its prompt. Attributed by the deployment, in the asking Bot's voice — the only voice + * that thread admits. + */ +describe("relaying the answer home", () => { + test("a delivered hop sends the answer back through the Bot that asked", async () => { + const { runner: sweeper, offered } = runner({ + answer: "The outage was Tuesday, 02:10 to 02:45.", + }); + + await sweeper.sweep(); + + expect(offered).toHaveLength(1); + expect(offered[0]).toMatchObject({ + fromBotId: "researcher", + toBotId: "assistant", + answerIn: "thread-1", + threadId: "thread-1", + depth: 1, + }); + expect(offered[0]?.task).toContain("find the outage window"); + expect(offered[0]?.task).toContain( + "The outage was Tuesday, 02:10 to 02:45.", + ); + }); + + test("its key is outside the run's own prefix, like the notice", async () => { + const { runner: sweeper, calls } = runner({ answer: "Tuesday." }); + + await sweeper.sweep(); + + const key = calls.find((call) => call.verb === "offer")?.key ?? ""; + expect(key.startsWith("run-1:")).toBe(false); + expect(key).toContain("run-1:abc"); + }); + + /* A relay of a relay is a loop. The `answerIn` marker that stops a notice stops this too. */ + test("a relay is not itself relayed", async () => { + const { runner: sweeper, offered } = runner({ + claimed: [ + { + kind: "bot.message", + key: "relay:run-1:abc", + payload: { ...WORK, answerIn: "thread-1" }, + attempts: 1, + }, + ] as unknown as WorkItem[], + answer: "Understood, telling them now.", + }); + + await sweeper.sweep(); + + expect(offered).toEqual([]); + }); + + test("a turn that said nothing sends nothing home", async () => { + const { runner: sweeper, offered } = runner({ answer: null }); + + await sweeper.sweep(); + + expect(offered).toEqual([]); + }); + + /* + * The answer rides inside the relaying run's prompt, and a Bot that came back with a book would + * spend that run's whole context window repeating it. + */ + test("an answer the length of a book is clipped, and says so", async () => { + const { runner: sweeper, offered } = runner({ + answer: "x".repeat(20_000), + }); + + await sweeper.sweep(); + + expect(offered[0]?.task).toContain("[…the answer was cut here for length]"); + expect((offered[0]?.task ?? "").length).toBeLessThan(14_000); + }); +}); + /** * What a notice leaves in the transcript. * diff --git a/server/tests/guards.test.ts b/server/tests/guards.test.ts index d4130d03..335c1de6 100644 --- a/server/tests/guards.test.ts +++ b/server/tests/guards.test.ts @@ -70,6 +70,8 @@ describe("server authorization", () => { name: "OpenBot Member", image: "https://example.test/member.png", role: "user", + // No store was passed, so this deployment tracks no onboarding and the app gates nobody. + onboarding: null, }, }); }); diff --git a/server/tests/onboarding-routes.test.ts b/server/tests/onboarding-routes.test.ts new file mode 100644 index 00000000..451be8d8 --- /dev/null +++ b/server/tests/onboarding-routes.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; +import { createApp } from "../src/app"; +import { loadConfig } from "../src/config"; +import type { OnboardingStore } from "../src/people/onboarding"; +import { testEnvironment } from "./support/environment"; + +const MEMBER = { + id: "member-1", + email: "member@openbot.test", + name: "A Member", + image: null, +}; + +/** + * The wizard's server half: /api/me carries where somebody is, and one POST moves them. + * + * The rules worth pinning are the shapes — what the app gates on has to keep meaning what it meant, + * and a body the route does not understand has to be refused rather than written. + */ +function appWith(store?: OnboardingStore) { + return createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: MEMBER }) }, + } as never, + { rolesForUser: async () => ["user"] }, + /* + * Positions 4-23 are the other stores; `store` is 24, onboardingStore, the signature's last. + * Every parameter from 4 on is optional, so a wrong count is a silent type-check pass — see + * people-routes.test.ts, which learned this the hard way. + */ + ...(Array.from({ length: 20 }) as never[]), + store as never, + ); +} + +/** A store holding one person's status in memory, with the same coalesce rule the real one has. */ +function memoryStore(initial: { step: number; completedAt: string | null }) { + const state = { ...initial }; + const store: OnboardingStore = { + status: async () => ({ ...state }), + setStep: async (_userId, step) => { + state.step = step; + }, + complete: async () => { + state.completedAt = state.completedAt ?? "2026-08-27T00:00:00.000Z"; + }, + }; + return { store, state }; +} + +describe("onboarding routes", () => { + test("/api/me carries the person's onboarding status", async () => { + const { store } = memoryStore({ step: 1, completedAt: null }); + const app = appWith(store); + + const response = await app.request("http://openbot.local/api/me"); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + user: { onboarding: { step: number; completedAt: string | null } }; + }; + expect(body.user.onboarding).toEqual({ step: 1, completedAt: null }); + }); + + test("/api/me reports null onboarding when the deployment has no store", async () => { + const app = appWith(undefined); + + const response = await app.request("http://openbot.local/api/me"); + + expect(response.status).toBe(200); + const body = (await response.json()) as { user: { onboarding: null } }; + expect(body.user.onboarding).toBeNull(); + }); + + test("a step moves the person and answers with where they are now", async () => { + const { store, state } = memoryStore({ step: 0, completedAt: null }); + const app = appWith(store); + + const response = await app.request( + "http://openbot.local/api/me/onboarding", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ step: 2 }), + }, + ); + + expect(response.status).toBe(200); + expect(state.step).toBe(2); + await expect(response.json()).resolves.toEqual({ + onboarding: { step: 2, completedAt: null }, + }); + }); + + test("completed: true stamps the completion", async () => { + const { store, state } = memoryStore({ step: 2, completedAt: null }); + const app = appWith(store); + + const response = await app.request( + "http://openbot.local/api/me/onboarding", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ completed: true }), + }, + ); + + expect(response.status).toBe(200); + expect(state.completedAt).not.toBeNull(); + const body = (await response.json()) as { + onboarding: { completedAt: string | null }; + }; + expect(body.onboarding.completedAt).toBe(state.completedAt); + }); + + test.each([ + [{}], + [{ step: -1 }], + [{ step: 1.5 }], + [{ step: "2" }], + [{ completed: false }], + // Past the column's range: written as sent, this would be a database error, not a bad request. + [{ step: 2_147_483_648 }], + ])("refuses a body it does not understand: %j", async (body) => { + const { store, state } = memoryStore({ step: 0, completedAt: null }); + const app = appWith(store); + + const response = await app.request( + "http://openbot.local/api/me/onboarding", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + + expect(response.status).toBe(400); + expect(state).toEqual({ step: 0, completedAt: null }); + }); + + test("answers 503 rather than pretending when there is no store", async () => { + const app = appWith(undefined); + + const response = await app.request( + "http://openbot.local/api/me/onboarding", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ completed: true }), + }, + ); + + expect(response.status).toBe(503); + }); +});