diff --git a/.env.example b/.env.example index ce629bfa..016e81df 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ DATABASE_URL=postgres://openbot:openbot@localhost:5432/openbot # development. Production refuses to start with this key. # openssl rand -base64 32 KEY_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +# How many days of audit trail to keep. Unset keeps everything, which is the safe default and the +# one to leave alone until somebody has decided otherwise: the trail is append-only and nothing else +# can remove a row, so this is the only way it ever shrinks. +# AUDIT_RETENTION_DAYS=365 PORT=3001 TENANT_PACKAGE_DIR=../examples/fintech # What this deployment calls itself, when more than one shares an Intelligence project. A copy of a diff --git a/agent-computer/src/browser-eviction.ts b/agent-computer/src/browser-eviction.ts new file mode 100644 index 00000000..8fa9bcc4 --- /dev/null +++ b/agent-computer/src/browser-eviction.ts @@ -0,0 +1,60 @@ +/** + * Which Bots' browsers to close, and when. + * + * Its own file, with no Playwright import, for the reason `authorisation.ts` and `bot-id.ts` are: + * `profiles.ts` imports Playwright at module scope, so anything living there needs a browser merely + * to be imported by a test. A decision this size should be testable without one, and `profiles.ts` + * owns the launching, so a test that went through it would be testing Playwright rather than the + * choice being made. + * + * The choice is worth testing on its own. There was no cap and no timeout: a context was started the + * first time each Bot was used and kept, and the only things that dropped one were an explicit stop, + * a browser that had already died, and shutdown. A deployment where every employee has a Bot trends + * toward one resident Chromium per employee in a single container, at a few hundred MB each, until + * it is killed for memory and relaunches its way back to the same state. + * + * Closing one loses nothing. The profile is on disk, so the Bot's logins survive and its next request + * starts where it left off, which is already what `stop` means here. + */ + +/** Enough of a live entry for either decision. Keeps this file independent of what else is on one. */ +export type Evictable = { usedAt: number }; + +/** + * Which to close because there are too many running. + * + * Least recently used first: the Bot that has been quiet longest. Applied after a launch, so the Bot + * that just asked is the most recently used and is therefore never the one closed. + */ +export function chooseEvictions( + running: Iterable<[string, Evictable]>, + max: number, +): string[] { + const entries = [...running]; + if (entries.length <= max) return []; + + return entries + .sort(([, a], [, b]) => a.usedAt - b.usedAt) + .slice(0, entries.length - max) + .map(([botId]) => botId); +} + +/** + * Which to close because nothing has touched them. + * + * The other half of the answer: a deployment under the cap still holds a browser for a Bot used once + * last Tuesday, and that memory is doing nothing for anybody. A timeout of zero or less switches this + * off, so a deployment can keep browsers resident if it would rather. + */ +export function chooseIdle( + running: Iterable<[string, Evictable]>, + idleTimeoutMs: number, + now: number, +): string[] { + if (idleTimeoutMs <= 0) return []; + const cutoff = now - idleTimeoutMs; + + return [...running] + .filter(([, entry]) => entry.usedAt <= cutoff) + .map(([botId]) => botId); +} diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index 350fe812..f2677593 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -17,12 +17,12 @@ import { type Screencast, startScreencast, } from "./screencast"; +import { createShell } from "./shell"; import { createWorkspace, WorkspaceFileError, WorkspacePathError, } from "./workspace"; -import { createShell } from "./shell"; /** * The Bot's computer: one long-lived browser, reachable over HTTP. @@ -122,11 +122,32 @@ type BotSession = { const sessions = new Map(); +/** + * Forget the sessions of Bots whose browsers are no longer running. + * + * The map gained an entry per Bot id this process had ever seen and lost none, so a deployment where + * every employee has a Bot accumulated one small object per employee for the life of the container. + * Small, but unbounded, which is the same shape as the browsers themselves. + * + * Only entries with no live browser and nobody watching are dropped: the state is the generation + * counter and the control handover, and both belong to a running browser. A Bot whose browser has + * been closed starts a fresh session next time, which is what starting a fresh browser means. + */ +function forgetIdleSessions(): void { + for (const [botId, session] of [...sessions.entries()]) { + if (session.viewer) continue; + if (profiles.isLive(botId)) continue; + sessions.delete(botId); + } +} + function sessionFor(botId: string): BotSession { const existing = sessions.get(botId); if (existing) return existing; const created: BotSession = { control: createControl(), snapshotId: 0 }; sessions.set(botId, created); + // Cheap, and only ever on the path that adds one, so the map cannot grow without this running. + if (sessions.size > 32) forgetIdleSessions(); return created; } diff --git a/agent-computer/src/profiles.ts b/agent-computer/src/profiles.ts index 368a93bf..d73c86fb 100644 --- a/agent-computer/src/profiles.ts +++ b/agent-computer/src/profiles.ts @@ -32,9 +32,11 @@ * operations this process applies to its own browser, so the same design works under Compose, * Kubernetes or ECS, where the orchestrator's own restart policy brings a process back. */ + import { readdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { type BrowserContext, chromium, type Page } from "playwright"; +import { chooseEvictions, chooseIdle } from "./browser-eviction"; import { egressFor, egressLabel } from "./egress"; /** The viewport, which is what a person's click coordinates are relative to. */ @@ -141,17 +143,104 @@ async function closeAndWait(context: BrowserContext): Promise { await new Promise((resolve) => setTimeout(resolve, CLOSE_SETTLE_MS)); } +/** + * How many browsers one computer holds at once. + * + * There was no cap. A context was started the first time each Bot was used and kept, and the only + * things that dropped one were an explicit stop, a browser that had already died, and shutdown. A + * deployment where every employee has a Bot therefore trends toward one resident Chromium per + * employee in a single container, at a few hundred MB each, until the container is killed for memory + * and `page()` relaunches its way back to the same state. + * + * A cap rather than only an idle timeout, because the failure is concurrent breadth rather than age: + * fifty people using their Bots inside the same minute are fifty live browsers and none of them are + * idle. The least recently used is closed, which is the one whose Bot has been quiet longest. + * + * Closing is not losing anything. The profile is on disk, so a Bot whose browser was closed starts + * again where it left off, which is what `stop` already means here. + */ +const MAX_LIVE_BROWSERS = Number(process.env.COMPUTER_MAX_BROWSERS ?? 8); + +/** + * How long a browser may sit untouched before it is closed. + * + * The other half. A deployment under the cap still holds a browser per Bot that was used once last + * Tuesday, and that memory is doing nothing for anybody. + */ +const IDLE_TIMEOUT_MS = Number( + process.env.COMPUTER_BROWSER_IDLE_MS ?? 30 * 60_000, +); + +/** How often the idle sweep looks. Cheap: it walks a map of at most `MAX_LIVE_BROWSERS`. */ +const IDLE_SWEEP_MS = 60_000; + export function createProfiles(root: string) { - /** One running browser per Bot. */ + /** One running browser per Bot, up to {@link MAX_LIVE_BROWSERS}. */ const live = new Map< string, - { context: BrowserContext; page: Page; startedAt: string } + { + context: BrowserContext; + page: Page; + startedAt: string; + /** When this Bot last asked for its page. Decides what the cap and the sweep close. */ + usedAt: number; + } >(); /** Launches in flight, so a cold computer is started once however many callers ask at once. */ const starting = new Map>(); const directoryFor = (botId: string): string => join(root, botId); + /** + * Close one Bot's browser and forget it. + * + * Gracefully, so Chromium flushes the profile: the whole point of closing one is that the Bot's + * logins survive and its next request starts where it left off. + */ + const evict = async (botId: string, reason: string): Promise => { + const running = live.get(botId); + if (!running) return; + live.delete(botId); + console.info( + JSON.stringify({ type: "computer-browser-closed", botId, reason }), + ); + await closeAndWait(running.context).catch(() => undefined); + }; + + /** + * Keep the number of running browsers under the cap. + * + * Least recently used first, which is the Bot that has been quiet longest. Called after a launch + * rather than before, so the Bot that just asked is never the one closed. + */ + const enforceCap = async (): Promise => { + for (const botId of chooseEvictions(live.entries(), MAX_LIVE_BROWSERS)) { + await evict(botId, "the cap on running browsers was reached"); + } + }; + + /** + * Close browsers nothing has touched for a while. + * + * The cap answers concurrent breadth; this answers a Bot used once last Tuesday whose browser is + * still resident and doing nothing for anybody. + */ + const sweepIdle = async (): Promise => { + for (const botId of chooseIdle( + live.entries(), + IDLE_TIMEOUT_MS, + Date.now(), + )) { + await evict(botId, "it had been idle"); + } + }; + + const idleSweep = setInterval(() => { + void sweepIdle().catch(() => undefined); + }, IDLE_SWEEP_MS); + // Housekeeping must not hold the process open on the way out. + idleSweep.unref?.(); + const sweepLocks = async (dir: string): Promise => { await Promise.all( SINGLETON_FILES.map((name) => @@ -181,6 +270,8 @@ export function createProfiles(root: string) { existing?.context.browser()?.isConnected() && !existing.page.isClosed() ) { + // Touched on every use, which is what makes "least recently used" mean anything. + existing.usedAt = Date.now(); return existing.page; } if (existing) { @@ -210,7 +301,15 @@ export function createProfiles(root: string) { }); // Persistent contexts open with a page already; reuse it rather than leaving an extra blank tab. const page = context.pages()[0] ?? (await context.newPage()); - live.set(botId, { context, page, startedAt: new Date().toISOString() }); + live.set(botId, { + context, + page, + startedAt: new Date().toISOString(), + usedAt: Date.now(), + }); + // After the new one is in the map, so the cap counts what is really running and the Bot that + // just asked is the most recently used and therefore never the one closed. + await enforceCap(); return page; })(); @@ -291,10 +390,26 @@ export function createProfiles(root: string) { * here gives Chromium the chance to flush its profile within that grace period. */ async closeAll(): Promise { + clearInterval(idleSweep); const contexts = [...live.values()]; live.clear(); await Promise.all(contexts.map((c) => closeAndWait(c.context))); }, + + /** How many browsers are running. For the idle sweep's own tests, and for a status reader. */ + liveCount(): number { + return live.size; + }, + + /** Whether this Bot has a browser right now, so a caller can drop state that belongs to one. */ + isLive(botId: string): boolean { + return live.has(botId); + }, + + /** Run the idle sweep now. Exposed so a test does not have to wait a minute for the interval. */ + sweepIdleNow(): Promise { + return sweepIdle(); + }, }; } diff --git a/agent-computer/tests/browser-eviction.test.ts b/agent-computer/tests/browser-eviction.test.ts new file mode 100644 index 00000000..d93404a4 --- /dev/null +++ b/agent-computer/tests/browser-eviction.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { chooseEvictions, chooseIdle } from "../src/browser-eviction"; + +/** + * How many browsers one computer holds, and for how long. + * + * There was no answer to either. A context was started the first time each Bot was used and kept, + * and the only things that dropped one were an explicit stop, a browser that had already died, and + * shutdown. A deployment where every employee has a Bot trends toward one resident Chromium per + * employee in a single container, at a few hundred MB each, until it is killed for memory and + * relaunches its way back to the same state. + * + * Closing one loses nothing: the profile is on disk, so the Bot's logins survive and its next + * request starts where it left off. That is already what `stop` means here. + * + * These test the decision rather than the closing. `createProfiles` launches a real Chromium, so a + * test that went through it would be testing Playwright; the part with a wrong answer available is + * which browser gets picked. + */ + +/** Bots, oldest use first, as a map the way the module holds them. */ +function running(...usedAt: number[]): Map { + return new Map(usedAt.map((at, index) => [`bot-${index}`, { usedAt: at }])); +} + +describe("keeping the number of running browsers under a cap", () => { + test("closes nothing while there is room", () => { + expect(chooseEvictions(running(1, 2, 3), 8)).toEqual([]); + }); + + test("closes nothing at exactly the cap", () => { + // The boundary. Off by one here closes a browser somebody is using every time the last slot + // fills, which reads as a computer that keeps restarting itself. + expect(chooseEvictions(running(1, 2, 3), 3)).toEqual([]); + }); + + test("closes the least recently used", () => { + // Not the oldest browser: the one whose Bot has been quiet longest. A Bot started this morning + // and used a second ago is the wrong one to close. + expect(chooseEvictions(running(500, 100, 300), 2)).toEqual(["bot-1"]); + }); + + test("closes as many as it takes to get under the cap", () => { + expect(chooseEvictions(running(500, 100, 300, 200), 2).sort()).toEqual([ + "bot-1", + "bot-3", + ]); + }); + + test("the one that just asked is never the one closed", () => { + /* + * The property that makes this safe. The cap is applied after a launch, so the newest entry is + * the most recently used; if it could be chosen, a deployment at the cap would close the browser + * it had just started and the Bot would never get one. + */ + const now = Date.now(); + const withNewest = running(now - 10_000, now - 5_000, now); + + expect(chooseEvictions(withNewest, 2)).not.toContain("bot-2"); + }); + + test("a cap of one still leaves the newest running", () => { + const now = Date.now(); + const chosen = chooseEvictions(running(now - 1000, now), 1); + + expect(chosen).toEqual(["bot-0"]); + }); +}); + +describe("closing browsers nothing has touched", () => { + const NOW = 1_000_000; + + test("closes what is past the timeout", () => { + const stale = NOW - 60_000; + const fresh = NOW - 1_000; + + expect(chooseIdle(running(stale, fresh), 30_000, NOW)).toEqual(["bot-0"]); + }); + + test("keeps one used exactly at the boundary out of it", () => { + // Exactly at the cutoff counts as idle, and a moment inside it does not. Stated so the boundary + // is a decision rather than whatever the comparison happened to be. + expect(chooseIdle(running(NOW - 30_000), 30_000, NOW)).toEqual(["bot-0"]); + expect(chooseIdle(running(NOW - 29_999), 30_000, NOW)).toEqual([]); + }); + + test("a timeout of zero switches it off", () => { + // A deployment that would rather keep browsers resident says so this way, and gets the cap and + // nothing else. + expect(chooseIdle(running(0), 0, NOW)).toEqual([]); + expect(chooseIdle(running(0), -1, NOW)).toEqual([]); + }); + + test("closes several at once", () => { + const stale = NOW - 60_000; + + expect(chooseIdle(running(stale, stale, NOW), 30_000, NOW).sort()).toEqual([ + "bot-0", + "bot-1", + ]); + }); + + test("nothing running closes nothing", () => { + expect(chooseIdle(running(), 30_000, NOW)).toEqual([]); + expect(chooseEvictions(running(), 8)).toEqual([]); + }); +}); diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index d06b65ca..b6e8d2dc 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -1,13 +1,18 @@ import { IconBolt, + IconBox, IconLogout, IconPlus, IconSearch, IconSettings, IconShieldLock, - IconBox, } from "@tabler/icons-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { Link, type LinkOptions, useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type * as React from "react"; @@ -34,7 +39,6 @@ import { SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar"; -import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion"; import { signOutMutationOptions } from "@/lib/auth/mutations"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { @@ -43,6 +47,7 @@ import { } from "@/lib/channels/queries"; import { useChannelEvents } from "@/lib/channels/use-channel-events"; import { appConfig } from "@/lib/generated/application-config"; +import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; import { Channel } from "./channel"; @@ -150,7 +155,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const queryClient = useQueryClient(); const navigate = useNavigate(); const signOut = useMutation(signOutMutationOptions(queryClient)); - const channels = useQuery(channelListQueryOptions()); + const channels = useInfiniteQuery(channelListQueryOptions()); // One socket for the app, opened where the roster is kept live. useChannelEvents(); const [search, setSearch] = useState(""); diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts index 21124dae..21ecbce7 100644 --- a/app/src/lib/channels/queries.ts +++ b/app/src/lib/channels/queries.ts @@ -1,4 +1,4 @@ -import { queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; import { client } from "@/lib/client"; /** @@ -32,14 +32,38 @@ export const channelKeys = { detail: (channelId: string) => ["channels", "detail", channelId] as const, }; +/** One page of channels, and where the next one starts. */ +export type ChannelPage = { + channels: ChannelSummary[]; + nextCursor: string | null; +}; + +/** + * The sidebar's channels, a page at a time. + * + * It used to ask for every channel this person has, one row per channel-agent pair, on every render. + * Nothing removes a channel, so somebody who talks to their Bot daily accumulates thousands and the + * query grows monotonically for as long as they use the product. + * + * The pages are flattened for the caller, so the sidebar and the socket that patches it both see one + * array in recency order and neither has to know this is paged. + */ export function channelListQueryOptions() { - return queryOptions({ + return infiniteQueryOptions({ queryKey: channelKeys.list(), - queryFn: async (): Promise => { - return client("/api/channels", "channels", { + initialPageParam: "", + queryFn: async ({ pageParam }): Promise => { + const suffix = pageParam + ? `?cursor=${encodeURIComponent(pageParam as string)}` + : ""; + const response = await client(`/api/channels${suffix}`, { fallback: "Could not load channels", }); + return (await response.json()) as ChannelPage; }, + getNextPageParam: (page: ChannelPage) => page.nextCursor ?? undefined, + select: (data): ChannelSummary[] => + data.pages.flatMap((page) => page.channels), }); } diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts index 4d4efe7e..d8737b86 100644 --- a/app/src/lib/channels/use-channel-events.ts +++ b/app/src/lib/channels/use-channel-events.ts @@ -1,6 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; -import { type ChannelSummary, channelKeys } from "./queries"; +import { type ChannelPage, type ChannelSummary, channelKeys } from "./queries"; /** * Keep the roster live. @@ -52,34 +52,55 @@ export function useChannelEvents() { return; } + /* + * The list is paged, so the cache holds pages rather than one array. + * + * The channel is patched inside whichever page holds it and that page is re-sorted. Sorting + * across pages is deliberately not attempted: a channel that has just become the most recent + * belongs at the top of page one, and moving a row between pages would fight the cursors the + * next fetch uses. The page it is on stays correct, and the next refetch puts it in order. + */ queryClient.setQueryData( channelKeys.list(), - (channels: ChannelSummary[] | undefined) => { - if (!channels) return channels; - // Unknown channel ids mean the roster is stale; refetch the list instead of patching. - if (!channels.some((c) => c.id === activity.channelId)) { + ( + data: { pages: ChannelPage[]; pageParams: unknown[] } | undefined, + ) => { + if (!data) return data; + + const holdingPage = data.pages.findIndex((page) => + page.channels.some( + (channel) => channel.id === activity.channelId, + ), + ); + // An unknown channel id means the roster is stale; refetch rather than patch. + if (holdingPage === -1) { void queryClient.invalidateQueries({ queryKey: channelKeys.list(), }); - return channels; + return data; } - // Preserve object identity for unchanged rows so memoized rows do not re-render. - const index = channels.findIndex( + + const page = data.pages[holdingPage] as ChannelPage; + const index = page.channels.findIndex( (channel) => channel.id === activity.channelId, ); - const previous = channels[index]; - if (!previous) return channels; + const previous = page.channels[index]; + if (!previous) return data; - const patched = { ...previous, ...activity }; - const next = channels.slice(); - next[index] = patched; + // Preserve object identity for unchanged rows so memoized rows do not re-render. + const next = page.channels.slice(); + next[index] = { ...previous, ...activity }; next.sort(byRecency); - // An event that changes nothing visible, a duplicate, or a report the server ignored - // as stale, returns the original array, so React re-renders nothing at all. - return next.every((channel, at) => channel === channels[at]) - ? channels - : next; + // An event that changes nothing visible, a duplicate, or a report the server ignored as + // stale, returns the original object, so React re-renders nothing at all. + if (next.every((channel, at) => channel === page.channels[at])) { + return data; + } + + const pages = data.pages.slice(); + pages[holdingPage] = { ...page, channels: next }; + return { ...data, pages }; }, ); }; diff --git a/app/src/lib/computers/activity.ts b/app/src/lib/computers/activity.ts index fb75f2f2..f6c29330 100644 --- a/app/src/lib/computers/activity.ts +++ b/app/src/lib/computers/activity.ts @@ -69,6 +69,30 @@ export function activityFor(computerId: string): ComputerActivity[] { return byComputer.get(computerId) ?? NONE; } +/** + * Whether this Bot has opened a page since the surface was loaded. + * + * The screen is a property of the computer and outlives a conversation: several Bots share one, and + * the profile keeps whatever page was last open. So a Bot that spends a whole conversation in a + * terminal still has a screen, showing somebody else's order form from an hour ago, and defaulting + * the pane to it captions a stale page as what this Bot is doing right now. That is worse than + * showing nothing, because it is confidently wrong. + * + * Recorded rather than inferred from the screenshot: a screenshot always succeeds, and "the browser + * has a page loaded" is a different fact from "this Bot opened one". + */ +const browsed = new Set(); + +export function noteBrowsed(computerId: string): void { + if (browsed.has(computerId)) return; + browsed.add(computerId); + for (const listener of listeners) listener(); +} + +export function hasBrowsed(computerId: string): boolean { + return browsed.has(computerId); +} + export function subscribeToActivity(listener: () => void): () => void { listeners.add(listener); return () => { @@ -84,5 +108,6 @@ export function subscribeToActivity(listener: () => void): () => void { */ export function clearActivity(computerId: string): void { byComputer.delete(computerId); + browsed.delete(computerId); for (const listener of listeners) listener(); } diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index ff12a34b..ceb134c8 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -4,7 +4,7 @@ import { ToolLine } from "@/components/channels/tool-line"; import { CommandOutput } from "@/components/computer/command-output"; import { ComputerView } from "@/components/computer/computer-view"; import { tryClient } from "@/lib/client"; -import { recordActivity } from "@/lib/computers/activity"; +import { noteBrowsed, recordActivity } from "@/lib/computers/activity"; import { type ControlState, readControl } from "@/lib/computers/control"; import { useActiveBotHolder } from "./active-bot"; import { reportComputerActivity } from "./computer-activity"; @@ -244,8 +244,9 @@ export function ComputerTools() { // Context is optional in the SDK. { signal }: { signal?: AbortSignal } = {}, ) => { + const computerId = bot.current; const result = await callComputer( - bot.current, + computerId, "/navigate", { method: "POST", @@ -253,6 +254,14 @@ export function ComputerTools() { }, signal, ); + /* + * This Bot has a page of its own now, so the pane may default to the screen. + * + * Until it does, the screen shows whatever the shared computer had open last, which may be + * another Bot's page from an hour ago. Captioning that as this Bot's screen is confidently + * wrong, and worse than showing nothing. + */ + if (result.ok) noteBrowsed(computerId); return result.ok ? { ok: true, diff --git a/app/src/lib/people/queries.ts b/app/src/lib/people/queries.ts index 7fff1c25..d7e7f574 100644 --- a/app/src/lib/people/queries.ts +++ b/app/src/lib/people/queries.ts @@ -1,4 +1,4 @@ -import { queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions } from "@tanstack/react-query"; import { client } from "@/lib/client"; /** @@ -29,15 +29,38 @@ export type Person = { export const peopleKeys = { all: ["people"] as const, - list: () => ["people", "list"] as const, + list: (search = "") => ["people", "list", { search }] as const, }; -export function peopleListQueryOptions() { - return queryOptions({ - queryKey: peopleKeys.list(), - queryFn: (): Promise => - client("/api/admin/people", "people", { +/** One page of people, and where the next one starts. */ +export type PeoplePage = { + people: Person[]; + nextCursor: string | null; +}; + +/** + * The people in this deployment, a page at a time. + * + * Paged because this list grows with the company. It used to fetch everybody on every render of the + * screen, joined to their roles, accounts and sessions. + * + * The search goes to the server rather than filtering what arrived, for the same reason: the point + * is to find somebody who is not on the first page. + */ +export function peopleListQueryOptions(search = "") { + return infiniteQueryOptions({ + queryKey: peopleKeys.list(search), + initialPageParam: "", + queryFn: ({ pageParam }): Promise => { + const query = new URLSearchParams(); + if (search) query.set("search", search); + if (pageParam) query.set("cursor", pageParam as string); + const suffix = query.size > 0 ? `?${query}` : ""; + + return client(`/api/admin/people${suffix}`, { fallback: "Could not load people", - }), + }).then((response) => response.json() as Promise); + }, + getNextPageParam: (page: PeoplePage) => page.nextCursor ?? undefined, }); } diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx index b2e00869..8040d2c4 100644 --- a/app/src/routes/_authed/_app/channel/$channelId.tsx +++ b/app/src/routes/_authed/_app/channel/$channelId.tsx @@ -13,7 +13,11 @@ import { useNeedsYou } from "@/components/computer/needs-you"; import { DetailPanel } from "@/components/layout/detail-panel"; import { Button } from "@/components/ui/button"; import { type AgentChannel, channelQueryOptions } from "@/lib/channels/queries"; -import { activityFor, subscribeToActivity } from "@/lib/computers/activity"; +import { + activityFor, + hasBrowsed, + subscribeToActivity, +} from "@/lib/computers/activity"; import { onComputerActivity } from "@/lib/copilot/computer-activity"; const chatSearchSchema = z.object({ @@ -53,26 +57,45 @@ function ComputerViewPanel({ agentId: string; name?: string; }) { - const [showing, setShowing] = useState<"screen" | "activity">("screen"); const activity = useSyncExternalStore( subscribeToActivity, () => activityFor(agentId), () => activityFor(agentId), ); + const browsed = useSyncExternalStore( + subscribeToActivity, + () => hasBrowsed(agentId), + () => hasBrowsed(agentId), + ); + + /* + * Which surface opens, decided by what the Bot is actually doing. + * + * The screen belongs to the computer rather than to the conversation: Bots share one and the + * profile keeps whatever page was open last. A Bot that spends a whole conversation in a terminal + * therefore has a screen showing somebody else's page from an hour ago, and defaulting to it + * captions that as what this Bot is doing now. + * + * So the screen is the default until there is a reason to think otherwise, and work away from the + * browser with no page opened is that reason. Once somebody picks a tab, their choice stands. + */ + const [chosen, setChosen] = useState<"screen" | "activity" | null>(null); + const showing = + chosen ?? (!browsed && activity.length > 0 ? "activity" : "screen"); return (
+ ) : null} ); diff --git a/server/drizzle/0006_audit_read_indexes.sql b/server/drizzle/0006_audit_read_indexes.sql new file mode 100644 index 00000000..3508e9ba --- /dev/null +++ b/server/drizzle/0006_audit_read_indexes.sql @@ -0,0 +1,7 @@ +ALTER TABLE "sso_providers" DROP CONSTRAINT "sso_providers_user_id_users_id_fk"; +--> statement-breakpoint +ALTER TABLE "accounts" ALTER COLUMN "issuer" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "sso_providers" ADD CONSTRAINT "sso_providers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_events_type_time_idx" ON "audit_events" USING btree ("event_type","created_at" DESC NULLS LAST,"id" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "audit_events_actor_time_idx" ON "audit_events" USING btree ("actor_user_id","created_at" DESC NULLS LAST,"id" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "audit_events_target_time_idx" ON "audit_events" USING btree ("target_type","target_id","created_at" DESC NULLS LAST,"id" DESC NULLS LAST); \ No newline at end of file diff --git a/server/drizzle/0007_audit_retention_window.sql b/server/drizzle/0007_audit_retention_window.sql new file mode 100644 index 00000000..965e1d97 --- /dev/null +++ b/server/drizzle/0007_audit_retention_window.sql @@ -0,0 +1,48 @@ +-- Let a retention policy remove old audit rows, and nothing else remove anything. +-- +-- The trail is append-only and that is enforced here rather than in the application, because the +-- application is not the only thing that can reach this table. That guarantee is worth keeping +-- exactly as strong as it was: a trail anybody can edit after the fact answers no question worth +-- asking, and "we deleted the rows about the incident" must stay impossible. +-- +-- A deployment still has to be able to say how long it keeps the trail. An enterprise buyer asks for +-- that as a control, and the table is the largest one in the deployment within weeks of real use. +-- +-- So DELETE becomes possible only under conditions the deleting statement has to state out loud: +-- +-- * the session sets `openbot.audit_retention_days` to a positive whole number, and +-- * the row is older than that many days. +-- +-- The setting is transaction-local in the sweep, so the permission cannot leak past the statement +-- that asked for it, and it cannot reach a recent row whatever it is set to. UPDATE stays refused +-- under every condition: retention removes history, it never rewrites it. +CREATE OR REPLACE FUNCTION prevent_audit_event_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + retention_days integer; +BEGIN + IF TG_OP = 'UPDATE' THEN + RAISE EXCEPTION 'Audit events are append-only'; + END IF; + + -- `true` so a session that never set it reads NULL instead of raising, which is the ordinary case + -- and has to stay a plain refusal. + BEGIN + retention_days := nullif(current_setting('openbot.audit_retention_days', true), '')::integer; + EXCEPTION WHEN others THEN + retention_days := NULL; + END; + + IF retention_days IS NULL OR retention_days < 1 THEN + RAISE EXCEPTION 'Audit events are append-only'; + END IF; + + IF OLD.created_at >= now() - (retention_days || ' days')::interval THEN + RAISE EXCEPTION 'Audit events are append-only within the retention window'; + END IF; + + RETURN OLD; +END; +$$; diff --git a/server/drizzle/meta/0006_snapshot.json b/server/drizzle/meta/0006_snapshot.json new file mode 100644 index 00000000..c3e09cd3 --- /dev/null +++ b/server/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2761 @@ +{ + "id": "e4274eaf-8276-46e6-8933-c88b68a1e83a", + "prevId": "107b8166-ff54-49c7-8871-fe1b5a4fbfbf", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": ["channel_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chunks": { + "name": "chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chunks_document_position_idx": { + "name": "chunks_document_position_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chunks_document_idx": { + "name": "chunks_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chunks_document_id_documents_id_fk": { + "name": "chunks_document_id_documents_id_fk", + "tableFrom": "chunks", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_cursors": { + "name": "connector_cursors", + "schema": "", + "columns": { + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_cursors_connector_instance_id_connector_instances_id_fk": { + "name": "connector_cursors_connector_instance_id_connector_instances_id_fk", + "tableFrom": "connector_cursors", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_instances": { + "name": "connector_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "connector_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_instances_credential_id_credentials_id_fk": { + "name": "connector_instances_credential_id_credentials_id_fk", + "tableFrom": "connector_instances", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_acls": { + "name": "document_acls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "acl_effect", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_acls_document_principal_effect_idx": { + "name": "document_acls_document_principal_effect_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effect", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_acls_principal_idx": { + "name": "document_acls_principal_idx", + "columns": [ + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_acls_document_id_documents_id_fk": { + "name": "document_acls_document_id_documents_id_fk", + "tableFrom": "document_acls", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_connector_source_idx": { + "name": "documents_connector_source_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_connector_deleted_idx": { + "name": "documents_connector_deleted_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_connector_instance_id_connector_instances_id_fk": { + "name": "documents_connector_instance_id_connector_instances_id_fk", + "tableFrom": "documents", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sync_runs_connector_started_at_idx": { + "name": "sync_runs_connector_started_at_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_runs_connector_instance_id_connector_instances_id_fk": { + "name": "sync_runs_connector_instance_id_connector_instances_id_fk", + "tableFrom": "sync_runs", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": ["user_id", "role"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_subscriptions": { + "name": "webhook_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": { + "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk", + "tableFrom": "webhook_subscriptions", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "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_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()" + } + }, + "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.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": ["component_name", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": ["component_name", "function_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": ["server_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": ["kind", "ref", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.acl_effect": { + "name": "acl_effect", + "schema": "public", + "values": ["allow", "deny"] + }, + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": ["built_in", "remote_ag_ui"] + }, + "public.connector_type": { + "name": "connector_type", + "schema": "public", + "values": ["google_drive", "onedrive"] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": ["model", "connector", "agent", "mcp"] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": ["admin", "user"] + }, + "public.sync_status": { + "name": "sync_status", + "schema": "public", + "values": ["pending", "running", "succeeded", "failed"] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/0007_snapshot.json b/server/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000..45bae271 --- /dev/null +++ b/server/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2761 @@ +{ + "id": "51e27ca5-9eb6-4fb3-9098-760f208403fc", + "prevId": "e4274eaf-8276-46e6-8933-c88b68a1e83a", + "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 + }, + "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 + }, + "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.chunks": { + "name": "chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chunks_document_position_idx": { + "name": "chunks_document_position_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "chunks_document_idx": { + "name": "chunks_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "chunks_document_id_documents_id_fk": { + "name": "chunks_document_id_documents_id_fk", + "tableFrom": "chunks", + "columnsFrom": ["document_id"], + "tableTo": "documents", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_cursors": { + "name": "connector_cursors", + "schema": "", + "columns": { + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_cursors_connector_instance_id_connector_instances_id_fk": { + "name": "connector_cursors_connector_instance_id_connector_instances_id_fk", + "tableFrom": "connector_cursors", + "columnsFrom": ["connector_instance_id"], + "tableTo": "connector_instances", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_instances": { + "name": "connector_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "connector_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_instances_credential_id_credentials_id_fk": { + "name": "connector_instances_credential_id_credentials_id_fk", + "tableFrom": "connector_instances", + "columnsFrom": ["credential_id"], + "tableTo": "credentials", + "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": {}, + "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.document_acls": { + "name": "document_acls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "acl_effect", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_acls_document_principal_effect_idx": { + "name": "document_acls_document_principal_effect_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effect", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "document_acls_principal_idx": { + "name": "document_acls_principal_idx", + "columns": [ + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "document_acls_document_id_documents_id_fk": { + "name": "document_acls_document_id_documents_id_fk", + "tableFrom": "document_acls", + "columnsFrom": ["document_id"], + "tableTo": "documents", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_connector_source_idx": { + "name": "documents_connector_source_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "documents_connector_deleted_idx": { + "name": "documents_connector_deleted_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "documents_connector_instance_id_connector_instances_id_fk": { + "name": "documents_connector_instance_id_connector_instances_id_fk", + "tableFrom": "documents", + "columnsFrom": ["connector_instance_id"], + "tableTo": "connector_instances", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sync_runs_connector_started_at_idx": { + "name": "sync_runs_connector_started_at_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "sync_runs_connector_instance_id_connector_instances_id_fk": { + "name": "sync_runs_connector_instance_id_connector_instances_id_fk", + "tableFrom": "sync_runs", + "columnsFrom": ["connector_instance_id"], + "tableTo": "connector_instances", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "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": "'{}'" + }, + "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.webhook_subscriptions": { + "name": "webhook_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": { + "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk", + "tableFrom": "webhook_subscriptions", + "columnsFrom": ["connector_instance_id"], + "tableTo": "connector_instances", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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_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()" + } + }, + "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.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": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "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.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.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 + } + }, + "enums": { + "public.acl_effect": { + "name": "acl_effect", + "schema": "public", + "values": ["allow", "deny"] + }, + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": ["built_in", "remote_ag_ui"] + }, + "public.connector_type": { + "name": "connector_type", + "schema": "public", + "values": ["google_drive", "onedrive"] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": ["model", "connector", "agent", "mcp"] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": ["admin", "user"] + }, + "public.sync_status": { + "name": "sync_status", + "schema": "public", + "values": ["pending", "running", "succeeded", "failed"] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index c033483a..cd2ea1f0 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1787322944029, "tag": "0005_computer_snapshot", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1787330336906, + "tag": "0006_audit_read_indexes", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1787330552602, + "tag": "0007_audit_retention_window", + "breakpoints": true } ] } diff --git a/server/src/agents/auth-header.ts b/server/src/agents/auth-header.ts index 1c03750c..83346903 100644 --- a/server/src/agents/auth-header.ts +++ b/server/src/agents/auth-header.ts @@ -71,6 +71,53 @@ export async function storeAgentAuth(input: { return { header: input.header, credentialId: credential.id }; } +/** + * Retire the key an edit has just replaced. + * + * Rotating a key is the standard answer to a suspected leak, and it only answers it if the old + * credential stops working. Editing a Bot's key used to write a new vault row and repoint the agent + * at it, leaving the previous one decryptable and still valid with nothing listing it and no way to + * reach it: the honest answer to "is that leaked key still live" was yes. The vault also grew one + * unrevoked secret per edit per Bot. + * + * Takes both configurations rather than a credential id, so the caller cannot get the direction + * wrong and revoke the key it has just stored. Does nothing when the id has not changed, which is an + * edit that left the key alone. + * + * Never throws. The new key is already stored and the Bot already works; a vault that would not + * accept the revocation is worth saying loudly and is not worth failing an edit that has succeeded. + */ +export async function retireReplacedKey( + store: Pick, + previous: Record, + next: Record, +): Promise { + const before = credentialIdOf(previous); + const after = credentialIdOf(next); + if (!before || before === after) return; + + try { + await store.revoke(before); + } catch (error) { + console.error( + JSON.stringify({ + type: "agent-key-revoke-failed", + credentialId: before, + note: "A Bot's key was replaced and the one it replaced is still live in the vault. Revoke it by hand.", + error: String(error), + }), + ); + } +} + +/** The vault row an agent configuration points at, if it points at one. */ +function credentialIdOf( + configuration: Record, +): string | undefined { + const auth = configuration.auth as { credentialId?: unknown } | undefined; + return typeof auth?.credentialId === "string" ? auth.credentialId : undefined; +} + /** * The headers to send to this agent, or none. * diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index a6f8da7f..98eaf565 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -7,7 +7,11 @@ import { agents, deploymentPackages, } from "../db/schema"; -import { authFromConfiguration, storeAgentAuth } from "./auth-header"; +import { + authFromConfiguration, + retireReplacedKey, + storeAgentAuth, +} from "./auth-header"; import { hashCallbackToken, mintCallbackToken, @@ -355,8 +359,12 @@ export function createAgentProfileStore( .from(agents) .where(eq(agents.id, id)) .limit(1); + const previous = (row?.configuration ?? {}) as Record< + string, + unknown + >; const configuration = { - ...((row?.configuration ?? {}) as Record), + ...previous, ...(input.endpoint ? { endpoint: input.endpoint } : {}), ...(input.auth && vault ? { @@ -370,6 +378,21 @@ export function createAgentProfileStore( } : {}), }; + + /* + * The key this one replaces is retired. + * + * Rotating a key is the standard answer to a suspected leak, and without this it did not + * answer it: the old credential stayed in the vault, decryptable and still valid, and + * nothing listed it or could reach it. "Is that leaked key still live" was yes. The + * credentials table also grew one unrevoked secret per edit per Bot. + * + * After the new one is stored, so a failure here leaves the Bot working with a key too + * many rather than with none. + */ + if (input.auth && vault) { + await retireReplacedKey(vault.store, previous, configuration); + } await transaction .update(agents) .set({ name: input.name, configuration, updatedAt }) @@ -455,6 +478,27 @@ export function createAgentProfileStore( .update(agentProfiles) .set({ deletedAt, updatedAt: deletedAt }) .where(eq(agentProfiles.agentId, id)); + + /* + * And its key stops working. + * + * A deleted Bot left its credential in the vault, decryptable and still valid, with + * nothing listing it and no screen able to reach it: deleting the Bot was the last chance + * anybody had to retire it. The profile is a soft delete, deliberately, but the key is not + * something to keep pending an undelete that would ask for a new one anyway. + */ + if (vault) { + const [row] = await transaction + .select({ configuration: agents.configuration }) + .from(agents) + .where(eq(agents.id, id)) + .limit(1); + await retireReplacedKey( + vault.store, + (row?.configuration ?? {}) as Record, + {}, + ); + } }, { isolationLevel: "read committed" }, ); diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index a48d15b6..4beb5ee5 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -1,6 +1,6 @@ import type { Context, MiddlewareHandler } from "hono"; import { Hono } from "hono"; -import type { AuditStore } from "../audit"; +import type { AuditEventType, AuditStore } from "../audit"; import { recordAuditEvent } from "../audit"; import type { AppVariables } from "../auth/guards"; import { testAgentConnection } from "./connection-test"; @@ -233,6 +233,45 @@ export function createAgentRoutes( return context.json(result); }); + /** + * Record something that changed a Bot. + * + * One helper rather than eight copies, because the eight routes below all answer the same question + * and the payload has to be the same shape for a reader filtering the trail. + * + * Never fatal. The change is already made and the caller has been told so; a trail that is briefly + * unavailable is not a reason to report a failure that did not happen. + */ + const record = async ( + context: Context<{ Variables: AppVariables }>, + eventType: Extract, + agentId: string, + payload: Record = {}, + ): Promise => { + if (!auditStore) return; + const actor = context.var.actor; + try { + await recordAuditEvent(auditStore, { + eventType, + targetType: "agent", + targetId: agentId, + ...(actor?.id && actor.email !== DEV_ACTOR_EMAIL + ? { actorUserId: actor.id } + : {}), + payload: { bot: agentId, actor: actor?.email ?? "unknown", ...payload }, + }); + } catch (error) { + console.error( + JSON.stringify({ + type: "bot-audit-write-failed", + eventType, + agentId, + error: String(error), + }), + ); + } + }; + routes.post("/", requireUser, async (context) => { // Malformed JSON is a recoverable client-input error and is validated by the same parser. const parsed = parseAgentInput( @@ -243,6 +282,15 @@ export function createAgentRoutes( try { const agent = await store.create(context.var.actor, parsed.value); + /* + * The endpoint, because that is where conversation content will be sent, and whether a key was + * attached, because "this Bot authenticates" is a fact and the key itself never is. + */ + await record(context, "bot.created", agent.id, { + name: parsed.value.name, + ...(parsed.value.endpoint ? { endpoint: parsed.value.endpoint } : {}), + hasKey: Boolean(parsed.value.auth), + }); return context.json({ agent: agentDto(context.var.actor, agent) }, 201); } catch (error) { return mapStoreError(context, error); @@ -263,6 +311,13 @@ export function createAgentRoutes( context.req.param("agentId"), parsed.value, ); + // What changed, not the new values. Repointing the endpoint is the dangerous edit and is worth + // naming; a replaced key is worth knowing about and is never worth recording. + await record(context, "bot.updated", agent.id, { + name: parsed.value.name, + ...(parsed.value.endpoint ? { endpoint: parsed.value.endpoint } : {}), + ...(parsed.value.auth ? { keyReplaced: true } : {}), + }); return context.json({ agent: agentDto(context.var.actor, agent) }); } catch (error) { return mapStoreError(context, error); @@ -275,6 +330,11 @@ export function createAgentRoutes( context.var.actor, context.req.param("agentId"), ); + // Recorded against the copy, naming the original: a duplicate inherits an endpoint, so the + // reader needs to know a second Bot now points at it. + await record(context, "bot.duplicated", agent.id, { + copiedFrom: context.req.param("agentId"), + }); return context.json({ agent: agentDto(context.var.actor, agent) }, 201); } catch (error) { return mapStoreError(context, error); @@ -288,6 +348,7 @@ export function createAgentRoutes( context.req.param("agentId"), true, ); + await record(context, "bot.hidden", context.req.param("agentId")); return context.body(null, 204); } catch (error) { return mapStoreError(context, error); @@ -301,6 +362,7 @@ export function createAgentRoutes( context.req.param("agentId"), false, ); + await record(context, "bot.unhidden", context.req.param("agentId")); return context.body(null, 204); } catch (error) { return mapStoreError(context, error); @@ -321,6 +383,13 @@ export function createAgentRoutes( context.var.actor, context.req.param("agentId"), ); + // That one was issued, never what it is. A trail that records credentials is a credential + // store with worse access control. + await record( + context, + "bot.callback_token_issued", + context.req.param("agentId"), + ); return context.json({ token }, 201); } catch (error) { return mapStoreError(context, error); @@ -334,6 +403,11 @@ export function createAgentRoutes( context.var.actor, context.req.param("agentId"), ); + await record( + context, + "bot.callback_token_revoked", + context.req.param("agentId"), + ); return context.body(null, 204); } catch (error) { return mapStoreError(context, error); @@ -343,6 +417,7 @@ export function createAgentRoutes( routes.delete("/:agentId", requireUser, async (context) => { try { await store.softDelete(context.var.actor, context.req.param("agentId")); + await record(context, "bot.deleted", context.req.param("agentId")); return context.body(null, 204); } catch (error) { return mapStoreError(context, error); diff --git a/server/src/app.ts b/server/src/app.ts index d8b8eb55..1c0516b4 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -266,7 +266,27 @@ export function createApp( return context.json({ error: "People are not available." }, 503); } - return context.json({ people: await peopleStore.list() }); + /* + * A page, not the deployment. + * + * `limit` is clamped by the store, so a caller cannot ask for everybody by naming a large + * number. `search` is what makes paging usable: an administrator looking for one colleague + * should not have to walk pages to reach them. + */ + const url = new URL(context.req.url); + const limit = Number.parseInt(url.searchParams.get("limit") ?? "", 10); + + return context.json( + await peopleStore.list({ + ...(url.searchParams.get("search") + ? { search: url.searchParams.get("search") as string } + : {}), + ...(url.searchParams.get("cursor") + ? { cursor: url.searchParams.get("cursor") as string } + : {}), + ...(Number.isFinite(limit) ? { limit } : {}), + }), + ); }); app.post("/api/admin/people/:userId/role", requireUser, async (context) => { diff --git a/server/src/audit-retention.ts b/server/src/audit-retention.ts new file mode 100644 index 00000000..5654969d --- /dev/null +++ b/server/src/audit-retention.ts @@ -0,0 +1,169 @@ +/** + * How long this deployment keeps its audit trail. + * + * Every click, keystroke, scroll, tool call, refusal, command and sign-in is a row, so the trail + * becomes the largest table in the deployment within weeks of real use and nothing in the product + * ever removed one. That is two problems wearing one hat. The table outgrows its disk, and an + * enterprise buyer asking "what is your retention policy" had no answer to be given: the honest one + * was "we keep everything forever and you cannot express anything else". + * + * Off unless configured. Deleting somebody's audit trail because a default said so is the worse + * failure of the two, and a deployment that has not thought about retention should keep everything + * until it has. + * + * The trail stays append-only. The database refuses every delete unless the transaction declares a + * retention window, and refuses any row inside that window whatever it is set to, so "we removed the + * rows about the incident" is still impossible and an UPDATE still is under every condition. See + * migration 0007. + * + * One server sweeps, not all of them, decided by a Postgres advisory lock. Without it, N servers run + * the same delete over the same rows every hour: N times the work for one outcome, on the one table + * every action writes to. + */ +import postgres from "postgres"; + +/** + * The advisory lock this takes, as an arbitrary but fixed number. + * + * Session-scoped, on a connection this module owns for the length of the sweep. It cannot go through + * the shared pool: a session lock belongs to one connection, so a pool would happily take it on one + * and try to release it on another, and the lock would leak until that connection died. + */ +const SWEEP_LOCK = 4_192_004; + +/** + * How many rows one delete removes. + * + * Batched because the alternative is a single statement holding locks on millions of rows on the + * table every action writes to. A deployment that has never swept has years to remove and should not + * take its own audit trail offline doing it. + */ +const BATCH = 5_000; + +/** How many batches one sweep runs before leaving the rest for the next one. */ +const MAX_BATCHES = 200; + +export type RetentionResult = { + /** Null when another server was already sweeping. Distinguished from having deleted nothing. */ + deleted: number | null; +}; + +/** + * Remove audit rows older than the retention window. + * + * Returns what it did so a caller can log it. Its own connection, released whatever happens. + */ +export async function sweepAuditTrail( + databaseUrl: string, + retentionDays: number, +): Promise { + if (!Number.isInteger(retentionDays) || retentionDays < 1) { + return { deleted: null }; + } + + const connection = postgres(databaseUrl, { max: 1 }); + + try { + const [lock] = await connection` + select pg_try_advisory_lock(${SWEEP_LOCK}) as held + `; + if (!lock?.held) return { deleted: null }; + + let deleted = 0; + for (let batch = 0; batch < MAX_BATCHES; batch += 1) { + const removed = await connection.begin(async (tx) => { + /* + * Declared to the trigger, transaction-locally. + * + * Retention is the one narrow exception to an append-only table and it has to say so. `true` + * makes the setting local to this transaction, so the permission cannot outlive the + * statement that asked for it. + */ + await tx`select set_config('openbot.audit_retention_days', ${String(retentionDays)}, true)`; + + /* + * `ctid` rather than a plain `delete ... where created_at <`. + * + * The subquery picks a bounded set using the `created_at` index and the delete removes + * exactly those, so each statement is short and the table stays writable between them. + */ + return await tx` + delete from audit_events where ctid in ( + select ctid from audit_events + where created_at < now() - (${retentionDays} || ' days')::interval + limit ${BATCH} + ) + `; + }); + + const count = removed.count ?? 0; + deleted += count; + if (count < BATCH) break; + } + + return { deleted }; + } finally { + // Ending the connection releases the lock with it, so a sweep that threw halfway does not leave + // the lock held and every later sweep skipped. + await connection.end({ timeout: 5 }).catch(() => undefined); + } +} + +export type RetentionSweeper = { stop: () => void }; + +/** + * Sweep on an interval, starting shortly after boot. + * + * Not immediately at boot: a deployment rolling several servers would have all of them contend for + * the lock in the same second, and the one that wins would compete with start-up for the database. + */ +export function startAuditRetention( + databaseUrl: string, + retentionDays: number | undefined, + options: { intervalMs?: number; firstRunMs?: number } = {}, +): RetentionSweeper { + if (!retentionDays || retentionDays < 1) return { stop: () => undefined }; + + const intervalMs = options.intervalMs ?? 60 * 60_000; + const firstRunMs = options.firstRunMs ?? 60_000; + const timers: ReturnType[] = []; + + const run = () => { + void sweepAuditTrail(databaseUrl, retentionDays) + .then(({ deleted }) => { + if (deleted === null || deleted === 0) return; + console.info( + JSON.stringify({ + type: "audit-retention-swept", + deleted, + retentionDays, + }), + ); + }) + .catch((error) => { + console.error( + JSON.stringify({ + type: "audit-retention-failed", + note: "Old audit rows were not removed. The trail is intact; it is only larger than the policy asks for.", + error: String(error), + }), + ); + }); + }; + + const first = setTimeout(() => { + run(); + const repeating = setInterval(run, intervalMs); + // Housekeeping should not hold the process open. + repeating.unref?.(); + timers.push(repeating); + }, firstRunMs); + first.unref?.(); + + return { + stop: () => { + clearTimeout(first); + for (const timer of timers) clearInterval(timer); + }, + }; +} diff --git a/server/src/audit.ts b/server/src/audit.ts index f288d3c0..5d346208 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -190,6 +190,25 @@ export const auditEventTypes = [ */ "identity_provider.registered", "identity_provider.removed", + /* + * What a Bot is and what it may reach. + * + * The trail recorded every mouse movement a Bot made and could not answer "who pointed this Bot at + * that host, and when", which is the first question asked in an incident. A Bot's endpoint is + * where conversation content is sent and its callback token is a capability handed to somebody + * else's infrastructure, so the two ends of both belong here. + * + * `bot.updated` carries what changed rather than the new values: the endpoint is worth naming, and + * a key never is. + */ + "bot.created", + "bot.updated", + "bot.duplicated", + "bot.hidden", + "bot.unhidden", + "bot.deleted", + "bot.callback_token_issued", + "bot.callback_token_revoked", ] as const; export type AuditEventType = (typeof auditEventTypes)[number]; diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index 4789811b..9dfa8065 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, isNull, lt, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm"; import type { Context, MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { @@ -20,8 +20,8 @@ import { type ChannelActivityEvent, type ChannelEventHub, } from "./events"; -import type { ThreadIdentity } from "./thread-identity"; import { upgradeWebSocket } from "./socket"; +import type { ThreadIdentity } from "./thread-identity"; export type AgentChannel = { id: string; @@ -47,10 +47,56 @@ export type ChannelActivity = { at: Date; }; +/** One page of somebody's channels, newest activity first. */ +export type ChannelPage = { + channels: ChannelSummary[]; + /** Where the next page starts, or null at the end. */ + nextCursor: string | null; +}; + +export type ChannelQuery = { cursor?: string; limit?: number }; + +/** + * How many channels one page holds. + * + * The sidebar asked for all of them on every render, one row per channel-agent pair, and nothing + * removes a channel: somebody who talks to their Bot daily accumulates thousands, so a query that is + * instant in a demo returns thousands of rows on every page load for every employee, and grows + * monotonically. A page is what a sidebar can show anyway. + */ +const DEFAULT_CHANNEL_PAGE = 50; + +/** The most a caller may ask for, so the endpoint cannot be talked back into reading everything. */ +const MAX_CHANNEL_PAGE = 200; + +/** Where a page stopped: both halves of the sort, since two channels can share a timestamp. */ +type ChannelCursor = { recency: string; id: string }; + +function encodeChannelCursor(cursor: ChannelCursor): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +/** A malformed cursor reads as the first page, which is the honest answer to a stale link. */ +function decodeChannelCursor( + value: string | undefined, +): ChannelCursor | undefined { + if (!value) return undefined; + try { + const parsed = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ) as ChannelCursor; + return typeof parsed?.id === "string" && typeof parsed?.recency === "string" + ? parsed + : undefined; + } catch { + return undefined; + } +} + export type ChannelStore = { create(actor: AgentActor, agentIds: string[]): Promise; get(actor: AgentActor, channelId: string): Promise; - list(actor: AgentActor): Promise; + list(actor: AgentActor, query?: ChannelQuery): Promise; recordActivity( actor: AgentActor, channelId: string, @@ -192,7 +238,57 @@ export function createChannelStore( }; }, - async list(actor) { + async list(actor, query = {}) { + const limit = Math.min( + Math.max(query.limit ?? DEFAULT_CHANNEL_PAGE, 1), + MAX_CHANNEL_PAGE, + ); + const cursor = decodeChannelCursor(query.cursor); + + /* + * The page of channels is chosen first, and the agents are joined to that page. + * + * The row set below is one row per channel-agent pair, so a limit on rows would cut a channel + * in half: its second Bot would arrive on the next page as a separate entry with the same id. + * Limiting the channels and then joining keeps a channel whole whatever it holds. + */ + const page = await database + .select({ + id: channels.id, + recency: sql`coalesce(${channels.lastMessageAt}, ${channels.createdAt})`, + }) + .from(channels) + .innerJoin( + channelMemberships, + and( + eq(channelMemberships.channelId, channels.id), + eq(channelMemberships.userId, actor.id), + ), + ) + .where( + cursor + ? sql`(coalesce(${channels.lastMessageAt}, ${channels.createdAt}), ${channels.id}) < (${cursor.recency}::timestamptz, ${cursor.id})` + : undefined, + ) + .orderBy( + sql`coalesce(${channels.lastMessageAt}, ${channels.createdAt}) desc`, + desc(channels.id), + ) + // One more than asked for, so "is there another page" needs no second count query. + .limit(limit + 1); + + const wanted = page.slice(0, limit); + const last = wanted.at(-1); + const nextCursor = + page.length > limit && last + ? encodeChannelCursor({ + recency: new Date(last.recency).toISOString(), + id: last.id, + }) + : null; + + if (wanted.length === 0) return { channels: [], nextCursor: null }; + const rows = await database .select({ id: channels.id, @@ -230,9 +326,15 @@ export function createChannelStore( // // The browser repeats this when the socket patches a row. Both must agree, or the list // reorders itself on the next event; see `byRecency` in use-channel-events.ts. + .where( + inArray( + channels.id, + wanted.map((row) => row.id), + ), + ) .orderBy( sql`coalesce(${channels.lastMessageAt}, ${channels.createdAt}) desc`, - asc(channels.id), + desc(channels.id), asc(channelAgents.agentId), ); @@ -258,7 +360,7 @@ export function createChannelStore( createdAt: row.createdAt, }); } - return [...summaries.values()]; + return { channels: [...summaries.values()], nextCursor }; }, recordActivity(actor, channelId, activity) { @@ -469,8 +571,19 @@ export function createChannelRoutes( routes.get("/", requireUser, async (context) => { try { - const channels = await store.list(context.var.actor); - return context.json({ channels: channels.map(channelSummaryDto) }); + const url = new URL(context.req.url); + const limit = Number.parseInt(url.searchParams.get("limit") ?? "", 10); + const page = await store.list(context.var.actor, { + ...(url.searchParams.get("cursor") + ? { cursor: url.searchParams.get("cursor") as string } + : {}), + ...(Number.isFinite(limit) ? { limit } : {}), + }); + + return context.json({ + channels: page.channels.map(channelSummaryDto), + nextCursor: page.nextCursor, + }); } catch (error) { return mapStoreError(context, error); } diff --git a/server/src/computer/policy-listener.ts b/server/src/computer/policy-listener.ts new file mode 100644 index 00000000..01dba02a --- /dev/null +++ b/server/src/computer/policy-listener.ts @@ -0,0 +1,50 @@ +/** + * Keep every server's copy of the boundary current. + * + * An administrator changes a rule on whichever server the load balancer picked. That server saves + * the row and announces it; this is the other end, on every server including the one that wrote it. + * + * Without it the rule applied on one server out of N and nothing said so. The admin screen reported + * success because the row really was saved, and the `computer.policy_loaded` audit row was written + * at boot, so both the screen and the trail agreed with each other and disagreed with what the fleet + * was enforcing. A deny rule that stops roughly one action in N is worse than no deny rule, because + * it looks like it works. + * + * 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. Same shape, and the same + * reason, as the channel activity listener. + */ +import postgres from "postgres"; +import { ACTION_POLICY_TOPIC, type PolicyStore } from "./policy-store"; + +export type PolicyListener = { stop: () => Promise }; + +export async function startPolicyListener( + databaseUrl: string, + store: PolicyStore, +): Promise { + const connection = postgres(databaseUrl, { max: 1 }); + + await connection.listen(ACTION_POLICY_TOPIC, () => { + /* + * The payload is ignored on purpose. It says the boundary moved, not what it moved to: a rule + * list can outgrow NOTIFY's 8000-byte cap, and a server enforcing whatever fitted in a payload + * would be a subtler version of the bug this fixes. The row is the record; this re-reads it. + */ + void store.refresh().catch((error) => { + console.error( + JSON.stringify({ + type: "action-policy-refresh-failed", + note: "This server was told the boundary changed and could not re-read it, so it is still enforcing the previous rules.", + error: String(error), + }), + ); + }); + }); + + return { + stop: async () => { + await connection.end(); + }, + }; +} diff --git a/server/src/computer/policy-store.ts b/server/src/computer/policy-store.ts index 053ddd15..94111bef 100644 --- a/server/src/computer/policy-store.ts +++ b/server/src/computer/policy-store.ts @@ -11,10 +11,17 @@ * and the memory copy is only updated once it has. A store that answered from the database on every * click would put a query on the path of every keystroke a Bot makes. * + * Memory is a cache of a shared record, not a per-process copy of it. OpenBot runs several servers + * behind a load balancer, and an administrator's new rule arrives at exactly one of them. Kept only + * in that process, the rule applies to roughly one action in N while the admin screen and the audit + * row both report success, which is the boundary silently not applying: the failure this whole file + * exists to prevent, in a different shape. So a write announces itself on Postgres and every server + * re-reads. Same mechanism as channel activity, for the same reason. + * * Without a database it still works in memory. Tests that only care about decision logic do not need * Postgres. */ -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import type { Database } from "../db/client"; import { actionPolicy } from "../db/schema"; import type { ActionPolicy } from "./policy"; @@ -22,6 +29,15 @@ import type { ActionPolicy } from "./policy"; /** There is one boundary per deployment, so there is one row. */ const CURRENT = "current"; +/** + * What a server announces on when the boundary changes, and what every server listens to. + * + * The payload is deliberately empty: it says "re-read", not what to read. A rule list can outgrow + * NOTIFY's 8000-byte cap, and a listener that took the payload as truth would be enforcing whatever + * fitted rather than what was saved. + */ +export const ACTION_POLICY_TOPIC = "action_policy_changed"; + /** * What a deployment allows when it has not said otherwise. * @@ -48,6 +64,13 @@ export type PolicyStore = { reset: () => Promise; /** Read the saved policy at boot. Returns where the live policy came from. */ load: () => Promise<"the database" | "configuration">; + /** + * Re-read because another server changed it. + * + * Separate from `load` so the caller reads as what it is. `load` reports where the policy came + * from for the boot audit row; this is the running deployment keeping up. + */ + refresh: () => Promise; }; export function createPolicyStore( @@ -89,6 +112,11 @@ export function createPolicyStore( }); } current = next; + + // Announced after the row is written, so a server that re-reads on this signal finds the new + // rule rather than the old one. Every server hears it, including this one, which re-reads and + // arrives at what it already has. + if (database) await announce(database); }, reset: async () => { @@ -99,6 +127,7 @@ export function createPolicyStore( await database.delete(actionPolicy).where(eq(actionPolicy.id, CURRENT)); } current = clone(configured); + if (database) await announce(database); }, load: async () => { @@ -117,9 +146,50 @@ export function createPolicyStore( }; return "the database"; }, + + refresh: async () => { + if (!database) return; + const [row] = await database + .select() + .from(actionPolicy) + .where(eq(actionPolicy.id, CURRENT)) + .limit(1); + + // No row means somebody reset it, and reset means this deployment goes back to what + // configuration says. Leaving the last saved rules in memory here would make a reset apply on + // the server that served it and nowhere else, which is the bug this function exists to fix. + current = row + ? { + mode: row.mode as ActionPolicy["mode"], + deny: [...row.deny], + allow: [...row.allow], + } + : clone(configured); + }, }; } +/** + * Tell every server the boundary moved. + * + * Never fatal. The rule is already saved and this process is already enforcing it, so a failed + * announcement costs the other servers their update until their next restart, which is worth a loud + * log and is not worth failing a write an administrator has been told succeeded. + */ +async function announce(database: Database): Promise { + try { + await database.execute(sql`select pg_notify(${ACTION_POLICY_TOPIC}, '')`); + } catch (error) { + console.error( + JSON.stringify({ + type: "action-policy-notify-failed", + note: "The boundary was saved but other servers were not told. They keep enforcing the previous rules until they restart.", + error: String(error), + }), + ); + } +} + function clone(policy: ActionPolicy): ActionPolicy { return { mode: policy.mode, diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index 4dc4198d..30f822b3 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -26,6 +26,14 @@ import { type PolicyStore, parseActionPolicy } from "./policy-store"; * Every computer call goes through the gateway. That is the governance seam: each acting route in * this file passes through a policy decision and audit row before it reaches the computer. */ +/** + * Paths under this router that are about the deployment rather than about a Bot. + * + * The bot-access middleware matches `/:botId/*`, and Hono's `/*` matches zero segments, so a + * single-segment path like `/policy` reaches it as a Bot id. These have their own guards. + */ +const DEPLOYMENT_ROUTES = new Set(["policy"]); + export function createComputerRoutes( gateway: ComputerGateway, policyStore: PolicyStore, @@ -50,6 +58,16 @@ export function createComputerRoutes( */ routes.use("/:botId/*", requireUser, async (context, next) => { const botId = context.req.param("botId"); + /* + * `/policy` is this router's own, and it is not about a Bot. + * + * Hono matches `/*` against zero segments, so `/policy` arrives here as a Bot called "policy", + * `canUseBot` quite correctly says there is no such Bot, and the Boundaries screen answers 404 + * for everybody including an administrator. Named rather than inferred from the segment count, + * because a second deployment-wide route added later should have to think about this line. + */ + if (botId && DEPLOYMENT_ROUTES.has(botId)) return next(); + if (botId && !(await canUseBot(context.var.actor, botId))) { return context.json({ error: "There is no such Bot." }, 404); } diff --git a/server/src/config.ts b/server/src/config.ts index 72ea9727..2981b1dd 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -110,6 +110,14 @@ export type DeploymentConfig = { * deployment does not acquire it without being asked. */ agentStallTimeoutMs: number; + /** + * How many days of audit trail this deployment keeps, or undefined to keep everything. + * + * Undefined by default. Deleting somebody's audit trail because a default said so is the worse of + * the two failures, and a deployment that has not thought about retention should keep everything + * until it has. + */ + auditRetentionDays: number | undefined; oauth: { google?: { clientId: string; clientSecret: string }; }; @@ -494,6 +502,26 @@ function accessibilityEnabled(environment: Environment): boolean { return off !== "true" && off !== "1"; } +/** + * How long the audit trail is kept. + * + * Refused rather than coerced, like everything else here. "We accepted your retention policy but not + * the one you wrote" is a bad answer about a control an auditor will ask to see, and a typo that + * silently became 0 would delete the trail rather than keep it. + */ +function auditRetentionDays(environment: Environment): number | undefined { + const raw = optional(environment, "AUDIT_RETENTION_DAYS"); + if (!raw) return undefined; + + const days = Number(raw); + if (!Number.isInteger(days) || days < 1) { + throw new Error( + "AUDIT_RETENTION_DAYS must be a whole number of days, at least 1. Leave it unset to keep the audit trail forever.", + ); + } + return days; +} + function agentStallTimeoutMs(environment: Environment): number { const raw = optional(environment, "AGENT_STALL_TIMEOUT_MS"); if (!raw) { @@ -528,6 +556,7 @@ export function loadConfig( optional(environment, "TENANT_PACKAGE_DIR") ?? "../examples/fintech", runtime: runtimeCapabilities(environment), agentStallTimeoutMs: agentStallTimeoutMs(environment), + auditRetentionDays: auditRetentionDays(environment), oauth: { google }, auth, singleUser: singleUserEnabled( diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts index 7547d34e..4dace4b9 100644 --- a/server/src/db/schema/core.ts +++ b/server/src/db/schema/core.ts @@ -445,7 +445,38 @@ export const auditEvents = pgTable( payload: jsonb("payload").notNull(), createdAt: createdAt(), }, - (table) => [index("audit_events_created_at_idx").on(table.createdAt)], + /* + * The trail becomes the largest table in the deployment within weeks of real use: every click, + * keystroke, scroll, tool call, refusal, command and sign-in is a row. + * + * One index on `created_at` served the unfiltered screen and nothing else. The audit screen filters + * by event type, by who did it, and by what it was done to, and every one of those was a sequential + * scan over the biggest table there is. Each filter therefore leads its own index and carries the + * sort, so the filtered read is one index scan rather than a scan plus a sort. + * + * `id` is in each of them because the keyset pages on `(created_at, id)`: two rows written in the + * same millisecond are ordered by id, and an index that stopped at the timestamp would leave the + * tie to be broken by a sort. + */ + (table) => [ + index("audit_events_created_at_idx").on(table.createdAt), + index("audit_events_type_time_idx").on( + table.eventType, + table.createdAt.desc(), + table.id.desc(), + ), + index("audit_events_actor_time_idx").on( + table.actorUserId, + table.createdAt.desc(), + table.id.desc(), + ), + index("audit_events_target_time_idx").on( + table.targetType, + table.targetId, + table.createdAt.desc(), + table.id.desc(), + ), + ], ); export const intelligenceChannelMappings = pgTable( diff --git a/server/src/index.ts b/server/src/index.ts index 4ff13dce..4cdac0bf 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,10 +1,10 @@ import { serve } from "bun"; -import { sql } from "drizzle-orm"; import { mintRunAssertion } from "./agents/callback-token"; import { createAgentProfileStore } from "./agents/profile-store"; import { createRuntimeAgentLoader } from "./agents/runtime-agents"; import { createApp } from "./app"; import { createAuditReader, createAuditStore, recordAuditEvent } from "./audit"; +import { startAuditRetention } from "./audit-retention"; import { createAuth } from "./auth"; import { DEV_ACTOR, initializeDevActorUser } from "./auth/dev-actor"; import { createRoleRepository } from "./auth/guards"; @@ -21,6 +21,7 @@ import { createThreadIdentity } from "./channels/thread-identity"; import { createSandboxedStore } from "./components/sandboxed"; import { createComponentStore } from "./components/store"; import { createComputerGateway } from "./computer/gateway"; +import { startPolicyListener } from "./computer/policy-listener"; import { createPolicyStore, DEFAULT_ACTION_POLICY, @@ -199,6 +200,17 @@ const policyStore = createPolicyStore( // A boundary an administrator set is read back before the first action is decided, so a restart no // longer silently returns to the configured default. const policySource = await policyStore.load(); +/* + * And kept current afterwards. + * + * A boundary an administrator changes arrives at one server. Without this, every other server keeps + * enforcing what it read at boot, so a new deny rule stops roughly one action in N while the screen + * and the audit row both report success. See policy-listener.ts. + */ +const policyListener = await startPolicyListener( + config.databaseUrl, + policyStore, +); /* * Record which boundary this process started with. @@ -210,6 +222,16 @@ const policySource = await policyStore.load(); * unavailable, and the row is a note for a reader rather than something the server depends on. */ const bootAuditStore = createAuditStore(database); +/* + * Old audit rows removed on a schedule, when a deployment has asked for that. + * + * One server sweeps rather than all of them, decided by an advisory lock. Off unless + * `AUDIT_RETENTION_DAYS` is set. See audit-retention.ts. + */ +const auditRetention = startAuditRetention( + config.databaseUrl, + config.auditRetentionDays, +); const computerGateway = computerProvider ? createComputerGateway({ provider: computerProvider, @@ -559,11 +581,15 @@ if (config.singleUser) { ); } -// The activity listener holds a connection of its own for the life of the process. Released on the -// way out, so a watch-mode restart does not leave one behind on every reload. +// Each listener holds a connection of its own for the life of the process. Released on the way out, +// so a watch-mode restart does not leave two behind on every reload. for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { - void channelActivityListener.stop().finally(() => process.exit(0)); + void Promise.allSettled([ + channelActivityListener.stop(), + policyListener.stop(), + Promise.resolve(auditRetention.stop()), + ]).finally(() => process.exit(0)); }); } diff --git a/server/src/people/store.ts b/server/src/people/store.ts index 110a2211..0caea03f 100644 --- a/server/src/people/store.ts +++ b/server/src/people/store.ts @@ -1,4 +1,4 @@ -import { eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { isConfiguredAdmin, type OpenBotRole, setRole } from "../auth/roles"; import type { Database } from "../db/client"; import { @@ -38,8 +38,38 @@ export type Person = { configuredAdmin: boolean; }; +/** One page of people, and whether there is another. */ +export type PeoplePage = { + people: Person[]; + /** + * The cursor for the next page, or null at the end. + * + * Keyset rather than an offset. An offset re-reads and discards everything before it, so page 50 + * is fifty times the work of page 1, and a person who signs in while somebody is paging shifts + * every later row by one and hides another person entirely. + */ + nextCursor: string | null; +}; + +/** What a page request may ask for. */ +export type PeopleQuery = { + /** Substring of the address or the name, case-insensitively. */ + search?: string; + /** From a previous page's `nextCursor`. */ + cursor?: string; + limit?: number; + /** One person, by id. Used by `find`, which needs the same aggregate for one row. */ + id?: string; +}; + export type PeopleStore = { - list: () => Promise; + /** + * One page of people, newest sign-in first. + * + * Bounded because this grows with the company. It used to take no arguments and return everybody, + * joined to their roles, accounts and sessions, on every render of the admin screen. + */ + list: (query?: PeopleQuery) => Promise; setRole: (userId: string, role: OpenBotRole) => Promise; revoke: (userId: string, revokedBy: string) => Promise; restore: (userId: string) => Promise; @@ -47,6 +77,54 @@ export type PeopleStore = { isRevoked: (email: string) => Promise; }; +/** How many people a page holds when the caller does not say. */ +const DEFAULT_PAGE = 50; + +/** + * The most a caller may ask for in one page. + * + * A ceiling rather than a suggestion, because the limit arrives over HTTP and the whole point of + * paging is that no single request can be made to read the entire deployment. + */ +const MAX_PAGE = 200; + +/** Where a page stopped. Both halves of the sort, because either alone is ambiguous. */ +type Cursor = { lastSignedInAt: string | null; email: string }; + +function encodeCursor(cursor: Cursor): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +/** + * Read a cursor a client sent back. + * + * A malformed one is treated as no cursor rather than as an error: it means the first page, which is + * a sensible answer to a stale or hand-edited link, and there is nothing here worth refusing over. + */ +function decodeCursor(value: string | undefined): Cursor | undefined { + if (!value) return undefined; + try { + const parsed = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ) as Cursor; + if (typeof parsed?.email !== "string") return undefined; + return { + email: parsed.email, + lastSignedInAt: + typeof parsed.lastSignedInAt === "string" + ? parsed.lastSignedInAt + : null, + }; + } catch { + return undefined; + } +} + +/** So a search for `100%` finds that and not everything. */ +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, (character) => `\\${character}`); +} + /** One spelling of an address, so a provider's choice of case cannot create a second person. */ function normalize(email: string): string { return email.trim().toLowerCase(); @@ -56,7 +134,22 @@ export function createPeopleStore( database: Database, initialAdminEmails: readonly string[], ): PeopleStore { - async function list(): Promise { + async function list(query: PeopleQuery = {}): Promise { + const limit = Math.min(Math.max(query.limit ?? DEFAULT_PAGE, 1), MAX_PAGE); + const cursor = decodeCursor(query.cursor); + const search = query.search?.trim(); + + const filters = []; + if (query.id) filters.push(eq(users.id, query.id)); + if (search) { + // Both fields, because an administrator looking for somebody has one or the other in mind and + // should not have to know which the deployment stored. + const pattern = `%${escapeLike(search)}%`; + filters.push( + sql`(${users.email} ilike ${pattern} escape '\\' or coalesce(${users.name}, '') ilike ${pattern} escape '\\')`, + ); + } + const rows = await database .select({ id: users.id, @@ -85,6 +178,7 @@ export function createPeopleStore( revokedAccess, eq(revokedAccess.email, sql`lower(${users.email})`), ) + .where(filters.length > 0 ? and(...filters) : undefined) .groupBy(users.id) /* * Most recently here first, and `NULLS LAST` on purpose. @@ -93,26 +187,70 @@ export function createPeopleStore( * signed in floats above everybody who just did. On a deployment of any size that is the * whole first screen given to people who have never used it. */ - .orderBy(sql`max(${sessions.createdAt}) desc nulls last`, users.email); - - return rows.map((row) => ({ - id: row.id, - email: row.email, - name: row.name, - image: row.image, - // `admin` wins, the same way the request guard reads it. Anything else is a plain user. - role: row.roles.includes("admin") ? "admin" : "user", - providers: row.providers, - lastSignedInAt: row.lastSignedInAt - ? new Date(row.lastSignedInAt).toISOString() - : null, - revoked: row.revoked === true, - configuredAdmin: isConfiguredAdmin(row.email, initialAdminEmails), - })); + /* + * The keyset, applied after grouping because it is about the aggregate. + * + * The sort is (last sign-in desc nulls last, email asc), so the cursor has to compare on both + * or two people who signed in within the same millisecond would hide each other. `nulls last` + * is why this is written out rather than a plain tuple comparison: a null on the descending + * side sorts after every value, which is the opposite of what `<` says about it. + */ + .having( + cursor + ? sql`( + (max(${sessions.createdAt}) is null and (${cursor.lastSignedInAt}::timestamptz is not null or ${users.email} > ${cursor.email})) + or (max(${sessions.createdAt}) is not null and ${cursor.lastSignedInAt}::timestamptz is not null and ( + max(${sessions.createdAt}) < ${cursor.lastSignedInAt}::timestamptz + or (max(${sessions.createdAt}) = ${cursor.lastSignedInAt}::timestamptz and ${users.email} > ${cursor.email}) + )) + )` + : undefined, + ) + .orderBy(sql`max(${sessions.createdAt}) desc nulls last`, users.email) + // One more than asked for, so "is there another page" is answered without a second count + // query over the same aggregate. + .limit(limit + 1); + + const page = rows.slice(0, limit); + const last = page.at(-1); + + return { + people: page.map((row) => ({ + id: row.id, + email: row.email, + name: row.name, + image: row.image, + // `admin` wins, the same way the request guard reads it. Anything else is a plain user. + role: row.roles.includes("admin") ? "admin" : "user", + providers: row.providers, + lastSignedInAt: row.lastSignedInAt + ? new Date(row.lastSignedInAt).toISOString() + : null, + revoked: row.revoked === true, + configuredAdmin: isConfiguredAdmin(row.email, initialAdminEmails), + })), + nextCursor: + rows.length > limit && last + ? encodeCursor({ + lastSignedInAt: last.lastSignedInAt + ? new Date(last.lastSignedInAt).toISOString() + : null, + email: last.email, + }) + : null, + }; } + /** + * One person, by id. + * + * Its own query. This used to be `(await list()).find(...)`, which ran the whole aggregate over + * every user in the deployment and filtered the result in JavaScript, and it is called twice by + * every role change and every access change. + */ async function find(userId: string): Promise { - return (await list()).find((person) => person.id === userId); + const { people } = await list({ id: userId, limit: 1 }); + return people[0]; } return { diff --git a/server/tests/agent-connection-live.test.ts b/server/tests/agent-connection-live.test.ts index 8427a2e9..b425c047 100644 --- a/server/tests/agent-connection-live.test.ts +++ b/server/tests/agent-connection-live.test.ts @@ -72,3 +72,107 @@ describe("registering an agent that really answers", () => { } }); }); + +/** + * The shapes a real agent turns up in, beyond the happy one. + * + * The connection test is what somebody sees when they register a colleague's agent, and the verdict + * decides whether they go looking at their own service or at ours. A stubbed SSE body proves we parse + * the string we wrote; these put a real AG-UI implementation on the other end and make it misbehave + * in the ways a real one does. + */ +describe("registering an agent that answers badly", () => { + test("an agent that starts a run and never finishes is still reported as working", async () => { + /* + * A run that produces events is a working endpoint. Whether the agent finishes its work is the + * agent's business and not something a registration check should refuse over: the alternative is + * a person unable to register a slow agent, told their perfectly good service is broken. + */ + const partial = new AGUIMock(); + partial.onRun(/.*/, [ + { type: "RUN_STARTED", threadId: "t", runId: "r" }, + { type: "TEXT_MESSAGE_START", messageId: "m", role: "assistant" }, + ] as never); + const partialUrl = await partial.start(); + + try { + const result = await testAgentConnection(partialUrl, { + allowPrivateHosts: true, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.events).toContain("RUN_STARTED"); + } finally { + await partial.stop?.(); + } + }); + + test("an agent that reports its own error still counts as speaking the protocol", async () => { + // `RUN_ERROR` is the agent telling us something went wrong, which means it is there and it + // speaks AG-UI. Refusing registration here would send somebody to debug the wrong end. + const failing = new AGUIMock(); + failing.onRun(/.*/, [ + { type: "RUN_STARTED", threadId: "t", runId: "r" }, + { type: "RUN_ERROR", message: "the model is unavailable" }, + ] as never); + const failingUrl = await failing.start(); + + try { + const result = await testAgentConnection(failingUrl, { + allowPrivateHosts: true, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.events).toContain("RUN_ERROR"); + } finally { + await failing.stop?.(); + } + }); + + test("a tool call in the stream is reported, because that is what a governed Bot does", async () => { + /* + * The event that matters most for this product. A remote Bot yields a tool call and OpenBot + * decides it, so an endpoint that emits one is exactly the shape the gateway is built for, and + * somebody registering it should see that it did. + */ + const calling = new AGUIMock(); + calling.onRun(/.*/, [ + { type: "RUN_STARTED", threadId: "t", runId: "r" }, + { + type: "TOOL_CALL_START", + toolCallId: "call_1", + toolCallName: "search_issues", + }, + { type: "TOOL_CALL_ARGS", toolCallId: "call_1", delta: '{"q":"x"}' }, + { type: "TOOL_CALL_END", toolCallId: "call_1" }, + { type: "RUN_FINISHED", threadId: "t", runId: "r" }, + ] as never); + const callingUrl = await calling.start(); + + try { + const result = await testAgentConnection(callingUrl, { + allowPrivateHosts: true, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.events).toContain("TOOL_CALL_START"); + } finally { + await calling.stop?.(); + } + }); + + test("nothing listening is a refusal a person can act on", async () => { + // The commonest registration mistake: a typo, or a service that is not up yet. It has to read as + // "we could not reach it" rather than as a protocol complaint. + const result = await testAgentConnection("http://127.0.0.1:9/ag-ui", { + allowPrivateHosts: true, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason.length).toBeGreaterThan(0); + }); +}); diff --git a/server/tests/audit-retention.integration.test.ts b/server/tests/audit-retention.integration.test.ts new file mode 100644 index 00000000..e8c6838c --- /dev/null +++ b/server/tests/audit-retention.integration.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { eq, sql } from "drizzle-orm"; +import postgres from "postgres"; +import { sweepAuditTrail } from "../src/audit-retention"; +import { createDatabase } from "../src/db/client"; +import { auditEvents } from "../src/db/schema"; +import { TEST_POOL } from "./support/database"; + +/** + * The audit trail has to be able to stop growing. + * + * Every click, keystroke, scroll, tool call, refusal, command and sign-in is a row, and nothing in + * the product ever removed one. That is the largest table in the deployment within weeks of real + * use, and it is also a control an enterprise buyer asks about directly: "what is your retention + * policy" had no answer other than "everything, forever, and you cannot change it". + * + * Against a real database because the whole thing is SQL: the advisory lock that decides which + * server sweeps, the batching, and the interval arithmetic. + */ + +const databaseUrl = + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot"; +const database = createDatabase(databaseUrl, TEST_POOL); + +/** The id column is a uuid, so the rows are found by their target rather than by a marker in the id. */ +const MARKER = "retention-test"; + +async function event(daysAgo: number, index: number): Promise { + await database.insert(auditEvents).values({ + eventType: "computer.action_allowed", + targetType: MARKER, + targetId: `${MARKER}-${index}`, + payload: {}, + createdAt: new Date(Date.now() - daysAgo * 86_400_000), + }); +} + +async function remaining(): Promise { + const rows = await database + .select({ id: auditEvents.id }) + .from(auditEvents) + .where(eq(auditEvents.targetType, MARKER)); + return rows.length; +} + +/* + * Cleaning up has to obey the same rule as everything else. + * + * A plain delete is refused, which is the guarantee working: the trail is append-only and the + * database enforces it. So the tests only ever create rows old enough for a one-day retention window + * to remove, and clean up by declaring that window the way the sweep does. + */ +afterEach(async () => { + await database.transaction(async (tx) => { + await tx.execute( + sql`select set_config('openbot.audit_retention_days', '1', true)`, + ); + await tx.delete(auditEvents).where(eq(auditEvents.targetType, MARKER)); + }); +}); + +/** + * Whether a rejection was the append-only trigger. + * + * Drizzle wraps the Postgres error, so the outer message is "Failed query: ..." and the reason is on + * the cause. Asserting only that it rejected would pass on a typo in the table name. + */ +async function refusedAsAppendOnly( + work: () => Promise, +): Promise { + try { + await work(); + return false; + } catch (error) { + let current: unknown = error; + for (let depth = 0; depth < 5 && current; depth += 1) { + if ( + String((current as { message?: string }).message ?? "").includes( + "append-only", + ) + ) { + return true; + } + current = (current as { cause?: unknown }).cause; + } + return false; + } +} + +describe("keeping the audit trail to a retention policy", () => { + test("removes what is older than the window and keeps the rest", async () => { + await event(400, 1); + await event(100, 2); + await event(10, 3); + await event(2, 4); + + const { deleted } = await sweepAuditTrail(databaseUrl, 90); + + expect(deleted).toBe(2); + expect(await remaining()).toBe(2); + }); + + test("keeps everything when no policy is set", async () => { + // The default, and the safe one. Deleting somebody's trail because a default said so is worse + // than a table that grows, so an unset or nonsense window sweeps nothing at all. + await event(400, 1); + + for (const window of [0, -1, Number.NaN]) { + const { deleted } = await sweepAuditTrail(databaseUrl, window); + expect(deleted).toBeNull(); + } + expect(await remaining()).toBe(1); + }); + + test("a row exactly inside the window survives", async () => { + // The boundary. Off by a day here means a deployment promising 90 days keeps 89. + await event(89, 1); + + await sweepAuditTrail(databaseUrl, 90); + + expect(await remaining()).toBe(1); + }); + + test("only one server sweeps at a time", async () => { + /* + * N servers running the same delete over the same rows every hour is N times the work for one + * outcome, on the largest table in the deployment and the one every action writes to. The + * advisory lock decides. A second sweep while one holds it reports null rather than zero, so a + * caller can tell "somebody else is doing it" from "there was nothing to do". + */ + await event(400, 1); + + /* + * Its own connection, standing in for the other server. A session advisory lock belongs to one + * connection and is re-entrant within it, so taking it through the shared pool would prove + * nothing: the sweep might be handed the same connection and sail through. + */ + const otherServer = postgres(databaseUrl, { max: 1 }); + try { + const [lock] = await otherServer` + select pg_try_advisory_lock(4192004) as held + `; + expect(lock?.held).toBe(true); + + const { deleted } = await sweepAuditTrail(databaseUrl, 90); + expect(deleted).toBeNull(); + expect(await remaining()).toBe(1); + } finally { + await otherServer.end({ timeout: 5 }).catch(() => undefined); + } + }); + + test("a recent row cannot be deleted, whatever the window says", async () => { + /* + * The line retention must not cross. "We deleted the rows about the incident" has to stay + * impossible, so the trigger refuses any row inside the declared window rather than trusting + * the statement to only ask for old ones. + */ + await event(2, 1); + + expect( + await refusedAsAppendOnly(() => + database.transaction(async (tx) => { + await tx.execute( + sql`select set_config('openbot.audit_retention_days', '3650', true)`, + ); + await tx + .delete(auditEvents) + .where(eq(auditEvents.targetType, MARKER)); + }), + ), + ).toBe(true); + + expect(await remaining()).toBe(1); + }); + + test("a row can never be edited, under any setting", async () => { + // Retention removes history; it does not rewrite it. An UPDATE is refused before the window is + // even considered. + await event(400, 1); + + expect( + await refusedAsAppendOnly(() => + database.transaction(async (tx) => { + await tx.execute( + sql`select set_config('openbot.audit_retention_days', '1', true)`, + ); + await tx + .update(auditEvents) + .set({ payload: { tampered: true } }) + .where(eq(auditEvents.targetType, MARKER)); + }), + ), + ).toBe(true); + }); + + test("a plain delete with no policy declared is still refused", async () => { + // What anybody reaching the table without going through the sweep gets, which is the case the + // trigger exists for. + await event(400, 1); + + expect( + await refusedAsAppendOnly(async () => { + await database + .delete(auditEvents) + .where(eq(auditEvents.targetType, MARKER)); + }), + ).toBe(true); + + expect(await remaining()).toBe(1); + }); + + test("the lock is released, so the next sweep can run", async () => { + // A sweep that kept the lock would be the last one this deployment ever ran. + await event(400, 1); + await sweepAuditTrail(databaseUrl, 90); + + await event(400, 2); + const { deleted } = await sweepAuditTrail(databaseUrl, 90); + + expect(deleted).toBe(1); + }); +}); diff --git a/server/tests/bot-lifecycle-audit.test.ts b/server/tests/bot-lifecycle-audit.test.ts new file mode 100644 index 00000000..934c08f2 --- /dev/null +++ b/server/tests/bot-lifecycle-audit.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { retireReplacedKey } from "../src/agents/auth-header"; +import { createAgentRoutes } from "../src/agents/routes"; +import type { AuditEventInput, AuditStore } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; + +/** + * The actions that change what a Bot is, and what it can reach. + * + * The trail recorded every mouse movement a Bot made and nothing about the Bot itself. Ten mutating + * routes wrote one audit row between them, and there was no event type for any of the other nine, so + * this was a missing vocabulary before it was a missing call. + * + * A Bot's endpoint is where conversation content is sent and its callback token is a capability + * handed to somebody else's infrastructure. "Who pointed this Bot at that host, and when" is the + * first question asked in an incident and the trail could not answer it. + */ + +const ACTOR = { id: "u1", email: "admin@openbot.test", role: "admin" } as const; + +function app(overrides: Record = {}) { + const rows: AuditEventInput[] = []; + const auditStore: AuditStore = { + insert: async (event) => void rows.push(event), + }; + + const store = { + create: async () => ({ id: "bot-1", name: "Sales" }), + update: async () => ({ id: "bot-1", name: "Sales" }), + duplicate: async () => ({ id: "bot-2", name: "Sales copy" }), + setHidden: async () => undefined, + softDelete: async () => undefined, + issueCallbackToken: async () => "the-token-nobody-records", + revokeCallbackToken: async () => undefined, + ...overrides, + } as never; + + const requireUser: MiddlewareHandler = async (context, next) => { + context.set("actor", ACTOR); + await next(); + }; + + const routes = createAgentRoutes(store, requireUser, true, auditStore); + return { rows, hono: new Hono().route("/api/agents", routes) }; +} + +type MiddlewareHandler = Parameters[1]; + +/** Everything the input parser insists on, so a test about auditing is not a test about validation. */ +const VALID = { + name: "Sales", + title: "Sales assistant", + roleDescription: "Helps with sales questions.", + visibility: "public", +}; + +const json = (body: Record) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...VALID, ...body }), +}); + +describe("what a Bot is, on the trail", () => { + test("creating one records the endpoint it was pointed at", async () => { + const { rows, hono } = app(); + + await hono.request( + "http://t/api/agents", + json({ endpoint: "https://partner.example/ag-ui" }), + ); + + expect(rows[0]?.eventType).toBe("bot.created"); + expect(rows[0]?.targetId).toBe("bot-1"); + expect(rows[0]?.payload.endpoint).toBe("https://partner.example/ag-ui"); + expect(rows[0]?.actorUserId).toBe("u1"); + }); + + test("repointing one records where to", async () => { + // The dangerous edit. It decides which host conversation content is sent to. + const { rows, hono } = app(); + + await hono.request("http://t/api/agents/bot-1", { + ...json({ endpoint: "https://elsewhere.example/ag-ui" }), + method: "PATCH", + }); + + expect(rows[0]?.eventType).toBe("bot.updated"); + expect(rows[0]?.payload.endpoint).toBe("https://elsewhere.example/ag-ui"); + }); + + test("a replaced key is noted and never recorded", async () => { + const { rows, hono } = app(); + + await hono.request("http://t/api/agents/bot-1", { + ...json({ + endpoint: "https://partner.example/ag-ui", + auth: { header: "Authorization", value: "Bearer sk-do-not-log-me" }, + }), + method: "PATCH", + }); + + expect(rows[0]?.payload.keyReplaced).toBe(true); + expect(JSON.stringify(rows[0]?.payload)).not.toContain("sk-do-not-log-me"); + }); + + test("issuing a callback token records that, never the token", async () => { + // A trail that records credentials is a credential store with worse access control. + const { rows, hono } = app(); + + const response = await hono.request( + "http://t/api/agents/bot-1/callback-token", + { method: "POST" }, + ); + + expect(await response.json()).toEqual({ + token: "the-token-nobody-records", + }); + expect(rows[0]?.eventType).toBe("bot.callback_token_issued"); + expect(JSON.stringify(rows[0])).not.toContain("the-token-nobody-records"); + }); + + test.each([ + ["/api/agents/bot-1/hide", "POST", "bot.hidden"], + ["/api/agents/bot-1/unhide", "POST", "bot.unhidden"], + [ + "/api/agents/bot-1/callback-token", + "DELETE", + "bot.callback_token_revoked", + ], + ["/api/agents/bot-1", "DELETE", "bot.deleted"], + ])("%s writes %s", async (path, method, eventType) => { + const { rows, hono } = app(); + + await hono.request(`http://t${path}`, { method }); + + expect(rows[0]?.eventType).toBe(eventType); + expect(rows[0]?.targetId).toBe("bot-1"); + }); + + test("duplicating records the copy and names the original", async () => { + // A duplicate inherits an endpoint, so a reader needs to know a second Bot now points at it. + const { rows, hono } = app(); + + await hono.request("http://t/api/agents/bot-1/duplicate", { + method: "POST", + }); + + expect(rows[0]?.eventType).toBe("bot.duplicated"); + expect(rows[0]?.targetId).toBe("bot-2"); + expect(rows[0]?.payload.copiedFrom).toBe("bot-1"); + }); + + test("a refused change writes nothing", async () => { + // The row says what happened. An attempt that the store rejected did not happen. + const { rows, hono } = app({ + update: async () => { + throw new Error("nope"); + }, + }); + + await hono.request("http://t/api/agents/bot-1", { + ...json({ endpoint: "https://partner.example/ag-ui" }), + method: "PATCH", + }); + + expect(rows).toHaveLength(0); + }); + + test("a trail that is down does not fail the change", async () => { + // The Bot is already updated and the caller has been told so. + const failing: AuditStore = { + insert: async () => { + throw new Error("the trail is unavailable"); + }, + }; + const requireUser: MiddlewareHandler = async (context, next) => { + context.set("actor", ACTOR); + await next(); + }; + const routes = createAgentRoutes( + { setHidden: async () => undefined } as never, + requireUser, + true, + failing, + ); + const hono = new Hono<{ Variables: AppVariables }>().route( + "/api/agents", + routes, + ); + + const response = await hono.request("http://t/api/agents/bot-1/hide", { + method: "POST", + }); + + expect(response.status).toBe(204); + }); +}); + +/** + * Rotating a key is the standard answer to a suspected leak, and it only answers it if the old one + * stops working. Editing a Bot's key wrote a new vault row and repointed the agent at it, leaving + * the previous one decryptable and still valid with nothing listing it. + */ +describe("retiring the key an edit replaced", () => { + function vault() { + const revoked: string[] = []; + return { + revoked, + store: { revoke: async (id: string) => void revoked.push(id) }, + }; + } + + test("revokes the one that was replaced", async () => { + const { revoked, store } = vault(); + + await retireReplacedKey( + store, + { auth: { credentialId: "old-key" } }, + { auth: { credentialId: "new-key" } }, + ); + + expect(revoked).toEqual(["old-key"]); + }); + + test("never revokes the one just stored", async () => { + // The direction that would be a catastrophe: the Bot would stop working the moment its key was + // rotated, and the leaked one would be the survivor. + const { revoked, store } = vault(); + + await retireReplacedKey( + store, + { auth: { credentialId: "same-key" } }, + { auth: { credentialId: "same-key" } }, + ); + + expect(revoked).toEqual([]); + }); + + test("does nothing when there was no key before", async () => { + const { revoked, store } = vault(); + + await retireReplacedKey(store, {}, { auth: { credentialId: "new-key" } }); + + expect(revoked).toEqual([]); + }); + + test("retires the key when a Bot is removed entirely", async () => { + // Deleting the Bot was the last chance anybody had to retire its credential. + const { revoked, store } = vault(); + + await retireReplacedKey(store, { auth: { credentialId: "old-key" } }, {}); + + expect(revoked).toEqual(["old-key"]); + }); + + test("a vault that refuses does not fail the edit", async () => { + // The new key is stored and the Bot works. This is loud and it is not fatal. + const store = { + revoke: async () => { + throw new Error("the vault is unavailable"); + }, + }; + + await expect( + retireReplacedKey( + store, + { auth: { credentialId: "old-key" } }, + { auth: { credentialId: "new-key" } }, + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/server/tests/channel-activity.integration.test.ts b/server/tests/channel-activity.integration.test.ts index e9d7a00f..06698aca 100644 --- a/server/tests/channel-activity.integration.test.ts +++ b/server/tests/channel-activity.integration.test.ts @@ -12,7 +12,6 @@ import { } from "../src/channels/routes"; import { createThreadIdentity } from "../src/channels/thread-identity"; import { createDatabase } from "../src/db/client"; -import { TEST_POOL } from "./support/database"; import { agentProfiles, agents, @@ -20,6 +19,7 @@ import { intelligenceChannelMappings, users, } from "../src/db/schema"; +import { TEST_POOL } from "./support/database"; const databaseUrl = process.env.DATABASE_URL ?? @@ -90,6 +90,118 @@ async function createChannel(owner: AgentActor, agentIds: string[]) { return channel; } +/** + * The sidebar asked for every channel this person has, on every render. + * + * One row per channel-agent pair, and nothing removes a channel: somebody who talks to their Bot + * daily accumulates thousands, so a query that is instant in a demo returns thousands of rows on + * every page load, for every employee, and grows for as long as they use the product. + * + * The page has to be chosen over channels rather than over rows, which is the whole subtlety here: a + * limit on rows would cut a two-Bot channel in half and its second Bot would arrive on the next page + * as a separate entry with the same id. + */ +describe("reading a person's channels", () => { + test("answers a page rather than everything", async () => { + const owner = await createUser(); + const agentId = await createAgent(owner); + for (let index = 0; index < 5; index += 1) { + await createChannel(owner, [agentId]); + } + + const page = await store.list(owner, { limit: 2 }); + + expect(page.channels).toHaveLength(2); + expect(page.nextCursor).not.toBeNull(); + }); + + test("walking the cursor reaches every channel exactly once", async () => { + const owner = await createUser(); + const agentId = await createAgent(owner); + const expected: string[] = []; + for (let index = 0; index < 5; index += 1) { + expected.push((await createChannel(owner, [agentId])).id); + } + + const seen: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page += 1) { + const result = await store.list(owner, { + limit: 2, + ...(cursor ? { cursor } : {}), + }); + seen.push(...result.channels.map((channel) => channel.id)); + if (!result.nextCursor) break; + cursor = result.nextCursor; + } + + expect(new Set(seen).size).toBe(seen.length); + expect(seen.sort()).toEqual(expected.sort()); + }); + + test("a channel with two Bots is never split across pages", async () => { + /* + * The reason the page is chosen over channels and the agents joined afterwards. Limiting the row + * set would put the channel's first Bot on one page and its second on the next, as two entries + * sharing an id, and the sidebar would render the same conversation twice with half its Bots. + */ + const owner = await createUser(); + const first = await createAgent(owner, "First"); + const second = await createAgent(owner, "Second"); + const shared = await createChannel(owner, [first, second]); + await createChannel(owner, [first]); + + const seen: { id: string; agentIds: string[] }[] = []; + let cursor: string | undefined; + for (let page = 0; page < 5; page += 1) { + const result = await store.list(owner, { + limit: 1, + ...(cursor ? { cursor } : {}), + }); + // One channel per page, whatever it holds. A row limit would put two here. + expect(result.channels.length).toBeLessThanOrEqual(1); + seen.push(...result.channels); + if (!result.nextCursor) break; + cursor = result.nextCursor; + } + + // Which of the two sorts first is incidental; that the shared one arrives once and whole is not. + const found = seen.filter((channel) => channel.id === shared.id); + expect(found).toHaveLength(1); + expect(found[0]?.agentIds.sort()).toEqual([first, second].sort()); + }); + + test("a caller cannot ask for every channel in one page", async () => { + // The limit arrives over HTTP, so the ceiling is what makes paging a property of the endpoint. + const owner = await createUser(); + const agentId = await createAgent(owner); + await createChannel(owner, [agentId]); + + const page = await store.list(owner, { limit: 100_000 }); + + expect(page.channels.length).toBeLessThanOrEqual(200); + }); + + test("a nonsense cursor reads as the first page", async () => { + const owner = await createUser(); + const agentId = await createAgent(owner); + await createChannel(owner, [agentId]); + + const page = await store.list(owner, { cursor: "not-a-cursor" }); + + expect(page.channels).toHaveLength(1); + }); + + test("somebody with no channels gets an empty page and no cursor", async () => { + const owner = await createUser(); + + const page = await store.list(owner); + + expect(page.channels).toEqual([]); + expect(page.nextCursor).toBeNull(); + }); +}); + /** * The roster reads the last thing said from our own row rather than from the Intelligence platform, * so it stays one indexed query however long the conversations get. What is stored is whatever the @@ -108,7 +220,7 @@ describe("channel activity", () => { text: "Categorized three expenses.", }); - expect(await store.list(owner)).toEqual([ + expect((await store.list(owner)).channels).toEqual([ { ...channel, lastMessage: "Categorized three expenses.", @@ -125,7 +237,7 @@ describe("channel activity", () => { const agentId = await createAgent(owner); const channel = await createChannel(owner, [agentId]); - expect(await store.list(otherUser)).toEqual([]); + expect((await store.list(otherUser)).channels).toEqual([]); await expect( store.recordActivity(otherUser, channel.id, { agentId: null, @@ -155,7 +267,9 @@ describe("channel activity", () => { text: "The question.", }); - expect((await store.list(owner))[0]?.lastMessage).toBe("The reply."); + expect((await store.list(owner)).channels[0]?.lastMessage).toBe( + "The reply.", + ); }); test("stores at most 200 code points, without control characters", async () => { @@ -170,7 +284,7 @@ describe("channel activity", () => { text: `line one\nline two \u001b[31m ${"x".repeat(400)}`, }); - const stored = (await store.list(owner))[0]?.lastMessage ?? ""; + const stored = (await store.list(owner)).channels[0]?.lastMessage ?? ""; expect(Array.from(stored).length).toBeLessThanOrEqual(200); // biome-ignore lint/suspicious/noControlCharactersInRegex: asserting they were removed. expect(stored).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/); @@ -209,10 +323,9 @@ describe("channel activity", () => { // about to type in. Sorting it under every channel that has a message would bury it. const fresh = await createChannel(owner, [agentId]); - expect((await store.list(owner)).map((channel) => channel.id)).toEqual([ - fresh.id, - used.id, - ]); + expect( + (await store.list(owner)).channels.map((channel) => channel.id), + ).toEqual([fresh.id, used.id]); }); test("sorts by recency and leaves silent channels below, not absent", async () => { @@ -227,9 +340,8 @@ describe("channel activity", () => { text: "Said something.", }); - expect((await store.list(owner)).map((channel) => channel.id)).toEqual([ - busy.id, - quiet.id, - ]); + expect( + (await store.list(owner)).channels.map((channel) => channel.id), + ).toEqual([busy.id, quiet.id]); }); }); diff --git a/server/tests/computer-policy-route.test.ts b/server/tests/computer-policy-route.test.ts new file mode 100644 index 00000000..9981e5c5 --- /dev/null +++ b/server/tests/computer-policy-route.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import type { MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import type { PolicyStore } from "../src/computer/policy-store"; +import { createComputerRoutes } from "../src/computer/routes"; + +/** + * The boundary is a fact about the deployment, not about a Bot. + * + * `/api/computers/policy` lives on the same router as the acting routes, which are all + * `/:botId/...`. The bot-access middleware matches `/:botId/*`, and Hono matches `/*` against zero + * segments, so `/policy` arrived at it as a Bot called "policy". `canUseBot` correctly answered that + * there is no such Bot, and the Boundaries screen returned 404 for everybody including an + * administrator: the whole surface for writing rules, gone, with a message about a Bot. + * + * Found by driving the screen rather than by reading the diff, which is the only way this was ever + * going to turn up: every test of both features passed. + */ + +const ADMIN = { id: "u1", email: "admin@openbot.test", role: "admin" } as const; + +function app(role: "admin" | "user" = "admin") { + const asActor: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { ...ADMIN, role }); + await next(); + }; + + const policyStore = { + get: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + set: async () => undefined, + } as unknown as PolicyStore; + + const routes = createComputerRoutes( + {} as never, + policyStore, + asActor, + // Nothing is a usable Bot here, which is exactly the deployment where the bug showed: the policy + // route must not depend on the caller having access to a Bot that happens to be named "policy". + async () => false, + ); + + return new Hono<{ Variables: AppVariables }>().route( + "/api/computers", + routes, + ); +} + +describe("reading and writing the deployment's boundary", () => { + test("an administrator can read it, whatever Bots they can reach", async () => { + const response = await app().request("http://t/api/computers/policy"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + policy: { mode: "enforce" }, + }); + }); + + test("an administrator can write it", async () => { + const response = await app().request("http://t/api/computers/policy", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "enforce", deny: [], allow: ["true"] }), + }); + + expect(response.status).toBeLessThan(300); + }); + + test("it is still administrator-only", async () => { + // The exemption is from the bot-access check, not from the admin check. Letting a plain user + // rewrite the boundary would be a far worse bug than the one being fixed. + const response = await app("user").request( + "http://t/api/computers/policy", + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "enforce", deny: [], allow: [] }), + }, + ); + + expect(response.status).toBe(403); + }); + + test("a Bot route is still gated", async () => { + // The exemption is one named path, not a hole. Everything else under this router still asks. + const response = await app().request( + "http://t/api/computers/some-bot/status", + ); + + expect(response.status).toBe(404); + }); +}); diff --git a/server/tests/mcp-protocol.test.ts b/server/tests/mcp-protocol.test.ts new file mode 100644 index 00000000..b6840b73 --- /dev/null +++ b/server/tests/mcp-protocol.test.ts @@ -0,0 +1,188 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { MCPMock } from "@copilotkit/aimock/mcp"; +import { callTool, listTools } from "../src/plugins/mcp"; + +/** + * The one door in this deployment that speaks MCP to somebody else's server, against something that + * really speaks it. + * + * `mcp.ts` builds a real MCP client over Streamable HTTP and every tool a Bot uses goes through it, + * so what it does with a reply is not a detail: the text lands in a model's context, the truncation + * decides how much of a context window somebody else's server may spend, and `isError` decides + * whether a Bot is told a tool failed or told nothing. + * + * None of that was tested. A stubbed fetch would prove we parse what we already believe the protocol + * says, and every one of these would still pass if MCP changed underneath us or if we had misread it + * in the first place. `@copilotkit/aimock` is ours, it is the org's deterministic backend for exactly + * this, and it tracks the protocol as the protocol moves: using it here means OpenBot finds out about + * a drift in the same week as everything else that depends on it, rather than in a customer's + * integration. + * + * Offline and deterministic. No key, no network, no spend, same answer every run, which is what lets + * it live in CI where a protocol contract most needs watching. + */ + +const mock = new MCPMock(); +let url = ""; + +/** Twenty thousand characters is the cap `mcp.ts` applies. This is comfortably past it. */ +const LONG = "x".repeat(25_000); + +/** + * A real one-pixel PNG. + * + * The MCP SDK validates that image data is base64, so a placeholder like "iVBOR" fails the whole + * call rather than the assertion. Worth knowing when writing a fixture: aimock will serve it. + */ +const PIXEL = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +beforeAll(async () => { + mock + .addTool({ + name: "search_issues", + description: "Find issues matching a query.", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }) + .onToolCall("search_issues", (args) => { + const { query } = (args ?? {}) as { query?: string }; + return `Found 2 issues for ${query}`; + }) + /* + * Every tool carries an `inputSchema`, including the ones that take nothing. + * + * The official MCP SDK validates the whole listing and rejects it if any tool omits one, so a + * single schemaless tool makes the server look completely broken rather than making that one + * tool uncallable. Worth knowing: aimock will happily serve a tool without it. + */ + .addTool({ + name: "long_answer", + description: "Returns far too much.", + inputSchema: { type: "object", properties: {} }, + }) + .onToolCall("long_answer", () => LONG) + .addTool({ + name: "returns_an_image", + description: "Not text.", + inputSchema: { type: "object", properties: {} }, + }) + .onToolCall("returns_an_image", () => [ + { type: "image", data: PIXEL, mimeType: "image/png" }, + ]) + .addTool({ + name: "mixed_content", + description: "Some text and something else.", + inputSchema: { type: "object", properties: {} }, + }) + .onToolCall("mixed_content", () => [ + { type: "text", text: "here is the chart" }, + { type: "image", data: PIXEL, mimeType: "image/png" }, + ]) + .addTool({ + name: "always_fails", + description: "Reports its own failure.", + inputSchema: { type: "object", properties: {} }, + }) + .onToolCall("always_fails", () => { + throw new Error("the vendor said no"); + }); + + url = await mock.start(); +}); + +afterAll(async () => { + await mock.stop?.(); +}); + +describe("listing what a server offers", () => { + test("reads the tools, their descriptions and their schemas", async () => { + const tools = await listTools({ url }); + + const search = tools.find((tool) => tool.name === "search_issues"); + expect(search?.description).toBe("Find issues matching a query."); + // The schema is handed to a model as the tool's contract, so an empty one is a tool the model + // cannot call correctly rather than a cosmetic loss. + expect(search?.inputSchema).toMatchObject({ type: "object" }); + }); + + test("a tool with no description reads as empty rather than undefined", async () => { + // The listing is projected into what a model sees. `undefined` there becomes the string + // "undefined" in a prompt often enough to be worth pinning. + const tools = await listTools({ url }); + const bare = tools.find((tool) => tool.name === "long_answer"); + + expect(typeof bare?.description).toBe("string"); + }); +}); + +describe("calling a tool", () => { + test("sends the arguments and returns what came back", async () => { + const result = await callTool({ url }, "search_issues", { + query: "billing", + }); + + // The arguments really crossed the wire: the answer is derived from them by the far side. + expect(result.text).toBe("Found 2 issues for billing"); + expect(result.isError).toBe(false); + expect(result.truncated).toBe(false); + }); + + test("a long answer is cut, and says it was cut", async () => { + /* + * A tool result goes straight into a model's context, so an unbounded one is somebody else's + * server deciding how much of our context window to spend. Truncated visibly, never silently: a + * model that can see the cut can say the answer was incomplete. + */ + const result = await callTool({ url }, "long_answer", {}); + + expect(result.truncated).toBe(true); + expect(result.text.length).toBeLessThan(LONG.length); + }); + + test("a non-text part is named rather than dropped", async () => { + // A model told "[image]" can say the tool returned an image. A model handed nothing concludes + // the tool returned nothing, which is a different and wrong answer. + const result = await callTool({ url }, "returns_an_image", {}); + + expect(result.text).toContain("[image]"); + }); + + test("text and a non-text part both survive", async () => { + const result = await callTool({ url }, "mixed_content", {}); + + expect(result.text).toContain("here is the chart"); + expect(result.text).toContain("[image]"); + }); + + test("a server that reports its own failure is distinguished from one that did not answer", async () => { + /* + * `isError` is the difference between "the tool ran and refused" and "we could not reach it". + * A Bot handed the first can explain itself; a Bot handed the second should retry or stop. The + * protocol carries the distinction and it would be easy to flatten both into a throw. + */ + const result = await callTool({ url }, "always_fails", {}); + + expect(result.isError).toBe(true); + expect(result.text.length).toBeGreaterThan(0); + }); + + test("a tool this server does not have fails rather than answering emptily", async () => { + await expect(callTool({ url }, "no_such_tool", {})).rejects.toBeInstanceOf( + Error, + ); + }); +}); + +describe("a server that cannot be reached", () => { + test("fails rather than answering with nothing", async () => { + // The case a stub cannot produce. A listing that returned `[]` for an unreachable server would + // present a Bot as having no tools rather than as having a broken connector. + await expect( + listTools({ url: "http://127.0.0.1:9/mcp" }), + ).rejects.toBeInstanceOf(Error); + }); +}); diff --git a/server/tests/people-paging.integration.test.ts b/server/tests/people-paging.integration.test.ts new file mode 100644 index 00000000..09479dab --- /dev/null +++ b/server/tests/people-paging.integration.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { inArray } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { sessions, users } from "../src/db/schema"; +import { createPeopleStore } from "../src/people/store"; +import { TEST_POOL } from "./support/database"; + +/** + * The people list has to stop growing with the company. + * + * `list()` took no arguments and returned everybody, joined to their roles, their linked provider + * accounts and every session they had ever held, on every render of the admin screen. `find()` was + * `(await list()).find(...)`, so looking up one person ran that whole aggregate and filtered the + * result in JavaScript, and every role change and every access change calls it twice. + * + * Against a real database, because what is being tested is the SQL: the keyset, the `nulls last` + * ordering the cursor has to agree with, and the search. A fake store would pass whatever shape it + * was written to return. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const store = createPeopleStore(database, []); +const PREFIX = "paging-test-"; +const ids: string[] = []; + +/** Somebody who signed in at a known moment, so the ordering is decidable rather than incidental. */ +async function person( + index: number, + signedInAt: Date | null, + name?: string, +): Promise { + const id = `${PREFIX}${String(index).padStart(3, "0")}`; + ids.push(id); + await database.insert(users).values({ + id, + email: `${id}@openbot.test`, + name: name ?? `Person ${index}`, + emailVerified: true, + }); + if (signedInAt) { + await database.insert(sessions).values({ + id: `${id}-session`, + userId: id, + token: `${id}-token`, + expiresAt: new Date(Date.now() + 86_400_000), + createdAt: signedInAt, + }); + } + return id; +} + +afterEach(async () => { + if (ids.length === 0) return; + await database.delete(sessions).where( + inArray( + sessions.userId, + ids.map((id) => id), + ), + ); + await database.delete(users).where(inArray(users.id, ids)); + ids.length = 0; +}); + +describe("reading the people in a deployment", () => { + test("answers a page rather than everybody", async () => { + const base = Date.now(); + for (let index = 0; index < 7; index += 1) { + await person(index, new Date(base - index * 60_000)); + } + + const page = await store.list({ limit: 3 }); + + expect(page.people).toHaveLength(3); + expect(page.nextCursor).not.toBeNull(); + }); + + test("walking the cursor reaches everybody exactly once", async () => { + // The property that matters. An offset would skip a person whenever somebody signed in while + // the caller was paging, and would re-read every earlier row to answer each page. + const base = Date.now(); + const expected: string[] = []; + for (let index = 0; index < 7; index += 1) { + expected.push(await person(index, new Date(base - index * 60_000))); + } + + const seen: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page += 1) { + const result = await store.list({ + limit: 2, + ...(cursor ? { cursor } : {}), + }); + seen.push(...result.people.map((one) => one.id)); + if (!result.nextCursor) break; + cursor = result.nextCursor; + } + + const mine = seen.filter((id) => id.startsWith(PREFIX)); + expect(new Set(mine).size).toBe(mine.length); + expect(mine.sort()).toEqual(expected.sort()); + }); + + test("somebody who has never signed in is last, not first", async () => { + /* + * Postgres sorts nulls first on a descending order, so without `nulls last` everybody who has + * never signed in floats above everybody who just did, and page one is entirely people who have + * never used the deployment. The cursor has to agree with that ordering or paging silently + * drops rows at the boundary. + */ + const base = Date.now(); + const recent = await person(0, new Date(base)); + const never = await person(1, null); + + const seen: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < 20; page += 1) { + const result = await store.list({ + limit: 1, + ...(cursor ? { cursor } : {}), + }); + seen.push(...result.people.map((one) => one.id)); + if (!result.nextCursor) break; + cursor = result.nextCursor; + } + + const mine = seen.filter((id) => id.startsWith(PREFIX)); + expect(mine).toEqual([recent, never]); + }); + + test("search finds somebody who is not on the first page", async () => { + // The reason search is server-side. Filtering the page that arrived would only ever search the + // first page, which is the one case where nobody needs help. + const base = Date.now(); + for (let index = 0; index < 6; index += 1) { + await person(index, new Date(base - index * 60_000)); + } + const wanted = await person(99, new Date(base - 3_600_000), "Grace Hopper"); + + const byName = await store.list({ search: "grace", limit: 2 }); + expect(byName.people.map((one) => one.id)).toEqual([wanted]); + + const byAddress = await store.list({ search: "paging-test-099", limit: 2 }); + expect(byAddress.people.map((one) => one.id)).toEqual([wanted]); + }); + + test("a search for a wildcard finds that, not everything", async () => { + // `%` and `_` are LIKE syntax. Unescaped, searching for `100%` returns the deployment. + await person(0, new Date(), "Ninety _ Percent"); + await person(1, new Date(Date.now() - 1000), "Somebody Else"); + + const result = await store.list({ search: "_ Percent", limit: 10 }); + expect(result.people).toHaveLength(1); + }); + + test("a caller cannot ask for the whole deployment in one page", async () => { + // The limit arrives over HTTP. A ceiling here is the thing that makes paging a property of the + // endpoint rather than a suggestion to the client. + for (let index = 0; index < 3; index += 1) { + await person(index, new Date(Date.now() - index * 1000)); + } + + const asked = await store.list({ limit: 100_000 }); + expect(asked.people.length).toBeLessThanOrEqual(200); + }); + + test("a nonsense cursor reads as the first page rather than an error", async () => { + // A stale link or a hand-edited URL. There is nothing here worth refusing over, and the first + // page is the honest answer to "I do not know where you were". + await person(0, new Date()); + + const result = await store.list({ cursor: "not-a-cursor", limit: 5 }); + expect(result.people.length).toBeGreaterThan(0); + }); + + test("finding one person does not read the deployment", async () => { + const base = Date.now(); + const wanted = await person(0, new Date(base)); + for (let index = 1; index < 5; index += 1) { + await person(index, new Date(base - index * 60_000)); + } + + const found = await store.find(wanted); + + expect(found?.id).toBe(wanted); + // The same shape the list answers with, because the screen renders both through one type. + expect(found?.email).toBe(`${wanted}@openbot.test`); + expect(found?.role).toBe("user"); + }); + + test("finding somebody who is not there answers undefined", async () => { + expect(await store.find("nobody-by-that-id")).toBeUndefined(); + }); +}); diff --git a/server/tests/people-routes.test.ts b/server/tests/people-routes.test.ts index cda5beab..71bc323d 100644 --- a/server/tests/people-routes.test.ts +++ b/server/tests/people-routes.test.ts @@ -41,7 +41,9 @@ function appWith( } { const calls: string[] = []; const store: PeopleStore = { - list: async () => people, + // One page, and no next one: what a small deployment answers. The paging itself is covered + // against a real database in people-paging.integration.test.ts. + list: async () => ({ people, nextCursor: null }), find: async (userId) => people.find((entry) => entry.id === userId), setRole: async (userId, next) => { calls.push(`setRole:${userId}:${next}`); @@ -86,7 +88,10 @@ describe("people routes", () => { const response = await request("/api/admin/people"); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ people: [person()] }); + expect(await response.json()).toEqual({ + people: [person()], + nextCursor: null, + }); }); // A plain user reading the list would learn every colleague's address and when they last signed diff --git a/server/tests/policy-fanout.integration.test.ts b/server/tests/policy-fanout.integration.test.ts new file mode 100644 index 00000000..c85e1183 --- /dev/null +++ b/server/tests/policy-fanout.integration.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { eq } from "drizzle-orm"; +import { startPolicyListener } from "../src/computer/policy-listener"; +import { + createPolicyStore, + DEFAULT_ACTION_POLICY, +} from "../src/computer/policy-store"; +import { createDatabase } from "../src/db/client"; +import { actionPolicy } from "../src/db/schema"; +import { TEST_POOL } from "./support/database"; + +/** + * A boundary an administrator changes has to reach every server, not the one that served the request. + * + * OpenBot runs several servers behind a load balancer. The policy is read from memory on every single + * action, which is right: a query per keystroke would be absurd. What was wrong is that memory was + * only ever filled at boot, so a new deny rule applied on the one server that happened to receive it + * and nowhere else. The admin screen reported success, because the row really was saved, and the + * audit trail agreed, because it records the boundary each process started with. Both were honest and + * both were describing something other than what the fleet was enforcing. + * + * A deny rule that stops roughly one action in N is worse than no deny rule at all: it looks like it + * works, so nobody goes looking. + * + * Another server is simulated by a second store on the same database with its own listener, the way + * the durability test simulates a restart. Reading back through the store that wrote it would only + * prove it remembers what it was told a moment ago, which was never the failure. + */ + +const databaseUrl = + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot"; +const database = createDatabase(databaseUrl, TEST_POOL); + +const RULE = 'contains(element.name, "submit")'; + +/** + * Wait for the other server to catch up. + * + * NOTIFY is delivered on commit and read on a second connection, so the update lands a moment after + * the write returns. Polled rather than slept, so the test is not a fixed delay that is either flaky + * or slow, and it fails on the timeout rather than on an assertion nobody can read. + */ +async function until( + condition: () => boolean, + timeoutMs = 3000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +afterEach(async () => { + await database.delete(actionPolicy).where(eq(actionPolicy.id, "current")); +}); + +describe("a rule added on one server", () => { + test("is enforced on a server that never received the request", async () => { + const wroteIt = createPolicyStore(DEFAULT_ACTION_POLICY, database); + const otherServer = createPolicyStore(DEFAULT_ACTION_POLICY, database); + await wroteIt.load(); + await otherServer.load(); + const listener = await startPolicyListener(databaseUrl, otherServer); + + try { + expect(otherServer.get().deny).toEqual([]); + + await wroteIt.set({ mode: "enforce", deny: [RULE], allow: ["true"] }); + + // Held in one process this stayed empty forever, and every click on that server went through. + await until(() => otherServer.get().deny.length > 0); + expect(otherServer.get().deny).toEqual([RULE]); + } finally { + await listener.stop(); + } + }); + + test("a reset reaches the other servers too", async () => { + // The direction that is easy to forget. A reset means the deployment goes back to what + // configuration says; if only the server that served it forgot the rule, the others would keep + // refusing actions an administrator believes they have just allowed again. + const wroteIt = createPolicyStore(DEFAULT_ACTION_POLICY, database); + const otherServer = createPolicyStore(DEFAULT_ACTION_POLICY, database); + await wroteIt.set({ mode: "enforce", deny: [RULE], allow: ["true"] }); + await otherServer.load(); + expect(otherServer.get().deny).toEqual([RULE]); + + const listener = await startPolicyListener(databaseUrl, otherServer); + try { + await wroteIt.reset(); + + await until(() => otherServer.get().deny.length === 0); + expect(otherServer.get().deny).toEqual([]); + expect(otherServer.get().allow).toEqual(DEFAULT_ACTION_POLICY.allow); + } finally { + await listener.stop(); + } + }); + + test("the mode travels, so dry-run does not stay on somewhere", async () => { + // Switching from dry-run to enforce is how an operator turns a boundary on for real. A server + // still in dry-run records refusals and forwards the actions anyway. + const wroteIt = createPolicyStore(DEFAULT_ACTION_POLICY, database); + const otherServer = createPolicyStore(DEFAULT_ACTION_POLICY, database); + await wroteIt.set({ mode: "dry-run", deny: [RULE], allow: ["true"] }); + await otherServer.load(); + expect(otherServer.get().mode).toBe("dry-run"); + + const listener = await startPolicyListener(databaseUrl, otherServer); + try { + await wroteIt.set({ mode: "enforce", deny: [RULE], allow: ["true"] }); + + await until(() => otherServer.get().mode === "enforce"); + expect(otherServer.get().mode).toBe("enforce"); + } finally { + await listener.stop(); + } + }); + + test("a store with no database is unaffected, so a test without Postgres still works", async () => { + // The in-memory store is what unit tests use. It has no fanout because it has no second process + // to fan out to, and asking it to announce would make every one of those tests need a database. + const store = createPolicyStore(DEFAULT_ACTION_POLICY); + await store.set({ mode: "enforce", deny: [RULE], allow: ["true"] }); + await store.refresh(); + + expect(store.get().deny).toEqual([RULE]); + }); +}); + +describe("the announcement itself", () => { + test("a saved rule is not lost when the announcement fails", async () => { + // The row is the record and this process is already enforcing it. Failing the write here would + // tell an administrator their rule was rejected when it is saved and live, which is worse than + // the other servers being briefly behind. + // A proxy rather than a spread: drizzle's methods do not survive being copied off the object, + // and a fake that had lost `insert` would fail this test for the wrong reason. + const broken = new Proxy(database, { + get: (target, property, receiver) => + property === "execute" + ? async () => { + throw new Error("pg_notify is unavailable"); + } + : Reflect.get(target, property, receiver), + }); + + const store = createPolicyStore(DEFAULT_ACTION_POLICY, broken); + await expect( + store.set({ mode: "enforce", deny: [RULE], allow: ["true"] }), + ).resolves.toBeUndefined(); + expect(store.get().deny).toEqual([RULE]); + }); +});