From da88cb4c28add257ac8b67081653ce7cb910a9f7 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Fri, 21 Aug 2026 09:38:04 -0700
Subject: [PATCH 1/7] A boundary change reaches every server, and the people
list stops growing
Two of the eight in #88, and the same shape: state a deployment shares was held per process, and
something that grows with use had nothing bounding it.
The policy is read from memory on every action, which is right, but memory was only ever filled at
boot. An administrator's new rule arrived at one server out of N and applied there and nowhere else,
while 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 honest, both describing something
other than what the fleet was enforcing. A deny rule that stops one action in N is worse than none,
because it looks like it works. A write now announces on Postgres and every server re-reads, the same
mechanism channel activity already uses. Reset travels too, which is the direction easy to forget.
The people list took no arguments and returned everybody, joined to their roles, their linked
accounts and every session they had ever held, on every render of the screen. `find` was
`(await list()).find(...)`, so reading one person ran that aggregate over the whole deployment, twice
per role change. It is a keyset page now, with server-side search, and `find` has its own query. The
cursor carries both halves of the sort because `nulls last` means a null compares the opposite way to
what `<` says about it.
Fourteen tests across two files, both against a real database, because what is being tested is the
SQL and the delivery rather than a shape a fake was written to return.
---
app/src/lib/people/queries.ts | 39 +++-
app/src/routes/_authed/admin/people.tsx | 56 ++++-
server/src/app.ts | 22 +-
server/src/computer/policy-listener.ts | 50 +++++
server/src/computer/policy-store.ts | 72 ++++++-
server/src/index.ts | 21 +-
server/src/people/store.ts | 178 ++++++++++++++--
.../tests/people-paging.integration.test.ts | 198 ++++++++++++++++++
server/tests/people-routes.test.ts | 9 +-
.../tests/policy-fanout.integration.test.ts | 155 ++++++++++++++
10 files changed, 759 insertions(+), 41 deletions(-)
create mode 100644 server/src/computer/policy-listener.ts
create mode 100644 server/tests/people-paging.integration.test.ts
create mode 100644 server/tests/policy-fanout.integration.test.ts
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/admin/people.tsx b/app/src/routes/_authed/admin/people.tsx
index 4f8a3924..d1956679 100644
--- a/app/src/routes/_authed/admin/people.tsx
+++ b/app/src/routes/_authed/admin/people.tsx
@@ -1,6 +1,7 @@
import { IconLock, IconShieldCheck, IconUser } from "@tabler/icons-react";
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
+import { useEffect, useState } from "react";
import {
PageEmpty,
PageRows,
@@ -9,6 +10,7 @@ import {
} from "@/components/layout/page-shell";
import { StaggerItem } from "@/components/layout/stagger";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import {
Item,
ItemActions,
@@ -60,7 +62,19 @@ function describe(person: Person): string {
}
function PeoplePage() {
- const people = useQuery(peopleListQueryOptions());
+ const [search, setSearch] = useState("");
+ /*
+ * Debounced, so typing a name is one request rather than one per keystroke against an aggregate
+ * over every user in the deployment.
+ */
+ const [query, setQuery] = useState("");
+ useEffect(() => {
+ const timer = setTimeout(() => setQuery(search), 250);
+ return () => clearTimeout(timer);
+ }, [search]);
+
+ const people = useInfiniteQuery(peopleListQueryOptions(query));
+ const rows = people.data?.pages.flatMap((page) => page.people) ?? [];
const currentUser = useQuery(currentUserQueryOptions());
const setRole = useMutation(setPersonRoleMutationOptions(queryClient));
const setAccess = useMutation(setPersonAccessMutationOptions(queryClient));
@@ -83,17 +97,31 @@ function PeoplePage() {
{failure.message}
) : null}
+ {/*
+ Server-side search. Filtering what already arrived would only search the first page, which
+ is the opposite of what somebody looking for a colleague needs.
+ */}
+ setSearch(event.target.value)}
+ placeholder="Search by name or address"
+ value={search}
+ />
+
{people.isPending ? null : people.error ? (
Could not load people.
- ) : people.data?.length === 0 ? (
+ ) : rows.length === 0 ? (
- Nobody has signed in yet. People appear here once they do.
+ {query
+ ? `Nobody here matches "${query}".`
+ : "Nobody has signed in yet. People appear here once they do."}
) : (
- {people.data?.map((person, index) => {
+ {rows.map((person, index) => {
const isSelf = person.id === currentUser.data?.id;
const busy = setRole.isPending || setAccess.isPending;
@@ -148,12 +176,28 @@ function PeoplePage() {
/>
- {index !== (people.data?.length ?? 0) - 1 && }
+ {index !== rows.length - 1 && }
);
})}
)}
+
+ {/*
+ Only when there is one. A button that says there is more when there is not is worse than
+ no button, and this list ends for most deployments on the first page.
+ */}
+ {people.hasNextPage ? (
+
+ ) : null}
);
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/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/index.ts b/server/src/index.ts
index 4ff13dce..0516149f 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -29,6 +29,7 @@ import {
createComputerProvider,
describeComputerIsolation,
} from "./computer/provider";
+import { startPolicyListener } from "./computer/policy-listener";
import { createSnapshotStore } from "./computer/snapshot-store";
import { loadConfig } from "./config";
import { createConnectorAdminService } from "./connectors";
@@ -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.
@@ -559,11 +571,14 @@ 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(),
+ ]).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/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]);
+ });
+});
From 8399a32cfedc34a7d3d30040fe04fb477e286e56 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Fri, 21 Aug 2026 09:46:41 -0700
Subject: [PATCH 2/7] The audit trail can be read quickly and, if a deployment
asks, kept for a stated time
Two more from #88, both about the table that becomes the largest in the deployment within weeks.
The audit screen filters by event type, by who did it and by what it was done to. There was one
index, on `created_at`, so every one of those filters was a sequential scan over the biggest table
there is, plus a sort. Each filter now leads its own index and carries the sort, and `id` is in all
of them because the keyset pages on `(created_at, id)`.
Nothing ever removed a row. That is a disk problem and, separately, a control an enterprise buyer
asks to see: "what is your retention policy" had no answer other than "everything, forever, and you
cannot express anything else". `AUDIT_RETENTION_DAYS` now expresses one. Unset by default, because
deleting somebody's trail because a default said so is the worse of the two failures.
The trail stays append-only, and that mattered more than the feature. It is enforced by a trigger,
deliberately, because the application is not the only thing that can reach the table. Rather than
work around it, the trigger now permits a delete only when the transaction declares a retention
window and only for rows already outside it, so "we removed the rows about the incident" is still
impossible and an UPDATE still is under every condition. Three of the eight tests exist to hold that
line rather than to test the feature.
One server sweeps, chosen by an advisory lock on a connection the sweep owns. It cannot go through
the pool: a session lock belongs to one connection, so the pool would take it on one and release it
on another and the lock would leak until that connection died. Found by the test that puts a real
second connection in the way.
Migration 0006 also repairs the snapshot chain, which two branches renumbering 0004 had left
disagreeing with itself. Verified by migrating a database from empty and checking the end state.
---
.env.example | 4 +
server/drizzle/0006_audit_read_indexes.sql | 7 +
.../drizzle/0007_audit_retention_window.sql | 48 +
server/drizzle/meta/0006_snapshot.json | 2761 +++++++++++++++++
server/drizzle/meta/0007_snapshot.json | 2761 +++++++++++++++++
server/drizzle/meta/_journal.json | 14 +
server/src/audit-retention.ts | 169 +
server/src/config.ts | 29 +
server/src/db/schema/core.ts | 33 +-
server/src/index.ts | 15 +-
.../tests/audit-retention.integration.test.ts | 224 ++
11 files changed, 6062 insertions(+), 3 deletions(-)
create mode 100644 server/drizzle/0006_audit_read_indexes.sql
create mode 100644 server/drizzle/0007_audit_retention_window.sql
create mode 100644 server/drizzle/meta/0006_snapshot.json
create mode 100644 server/drizzle/meta/0007_snapshot.json
create mode 100644 server/src/audit-retention.ts
create mode 100644 server/tests/audit-retention.integration.test.ts
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/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/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/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 0516149f..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,
@@ -29,7 +30,6 @@ import {
createComputerProvider,
describeComputerIsolation,
} from "./computer/provider";
-import { startPolicyListener } from "./computer/policy-listener";
import { createSnapshotStore } from "./computer/snapshot-store";
import { loadConfig } from "./config";
import { createConnectorAdminService } from "./connectors";
@@ -222,6 +222,16 @@ const policyListener = await startPolicyListener(
* 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,
@@ -578,6 +588,7 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) {
void Promise.allSettled([
channelActivityListener.stop(),
policyListener.stop(),
+ Promise.resolve(auditRetention.stop()),
]).finally(() => process.exit(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);
+ });
+});
From 5a01e7217ff680c8b3676879b0f96c4a7f6e78bc Mon Sep 17 00:00:00 2001
From: David McKay
Date: Fri, 21 Aug 2026 09:51:44 -0700
Subject: [PATCH 3/7] Rotating a Bot's key retires the old one, and changing a
Bot leaves a trace
Two more from #88.
Editing a Bot's key wrote a new vault row and repointed the Bot at it. The previous credential stayed
in the vault, decryptable and still valid, with nothing listing it and no path able to revoke it, so
the honest answer to "is that leaked key still live" was yes, and rotation did not do the one thing
rotation is for. Deleting the Bot left its key live too, which was the last chance anybody had to
retire it. Both now revoke, after the replacement is stored so a failure leaves a Bot with a key too
many rather than none, and loudly rather than fatally because the edit has already succeeded.
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 it
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 in an incident and could not be answered.
Eight event types and eight rows now, through one helper so the payload cannot drift between them.
What is recorded is what changed, never a value: a replaced key is `keyReplaced: true` and an issued
callback token is the fact it was issued. A trail that records credentials is a credential store with
worse access control. Two of the sixteen tests exist to hold that, and one holds the direction that
would be a catastrophe: rotation must never revoke the key it has just stored.
---
server/src/agents/auth-header.ts | 47 ++++
server/src/agents/profile-store.ts | 48 +++-
server/src/agents/routes.ts | 77 ++++++-
server/src/audit.ts | 19 ++
server/tests/bot-lifecycle-audit.test.ts | 273 +++++++++++++++++++++++
5 files changed, 461 insertions(+), 3 deletions(-)
create mode 100644 server/tests/bot-lifecycle-audit.test.ts
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/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/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();
+ });
+});
From 27cb573e2921f72928b554d8419af37ce4bed4d4 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Fri, 21 Aug 2026 10:03:04 -0700
Subject: [PATCH 4/7] Bound the browsers a computer holds and the channels a
sidebar asks for
The last two of the eight in #88 that are mine, and a bug the Activity pane had from the start.
A Chromium context was started the first time each Bot was used and kept. The only things that
dropped one were an explicit stop, a browser that had already died, and shutdown, so a deployment
where every employee has a Bot trends toward one resident browser 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. There is now a cap and an idle timeout. A cap as well as a 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. Closing one costs a relaunch and nothing else, since the
profile is on disk. The per-Bot session map had the same shape and is bounded the same way.
The sidebar asked for every channel this person has on every render, one row per channel-agent pair.
It is a keyset page now. The page is chosen over channels and the agents joined to it afterwards,
which is the whole subtlety: 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. The socket that patches the list
live had to learn about pages with it.
The Activity pane defaulted to the screen even when the Bot had never opened a page. The screen
belongs to the computer rather than to the conversation, so a Bot that spent a whole conversation in
a terminal showed somebody else's order form from an hour ago, captioned as its own live screen. The
pane now opens on whichever surface the Bot is actually using, and the caption says whose page it is
only once this Bot has opened one.
The eviction decision is a pure function so it can be tested at all: createProfiles launches a real
Chromium, so a test that went through it would be testing Playwright.
---
agent-computer/src/index.ts | 23 ++-
agent-computer/src/profiles.ts | 162 +++++++++++++++++-
agent-computer/tests/browser-eviction.test.ts | 107 ++++++++++++
.../components/app-sidebar/app-sidebar.tsx | 13 +-
app/src/lib/channels/queries.ts | 32 +++-
app/src/lib/channels/use-channel-events.ts | 57 ++++--
app/src/lib/computers/activity.ts | 25 +++
app/src/lib/copilot/computer-tools.tsx | 13 +-
.../_authed/_app/channel/$channelId.tsx | 42 ++++-
server/src/channels/routes.ts | 129 +++++++++++++-
.../channel-activity.integration.test.ts | 138 +++++++++++++--
11 files changed, 682 insertions(+), 59 deletions(-)
create mode 100644 agent-computer/tests/browser-eviction.test.ts
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..0ce2d41b 100644
--- a/agent-computer/src/profiles.ts
+++ b/agent-computer/src/profiles.ts
@@ -141,17 +141,147 @@ 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;
+
+/**
+ * Which Bots' browsers to close, given what is running.
+ *
+ * Pure, and exported, because the rest of this module cannot be reached without launching a real
+ * Chromium: `createProfiles` owns the launch, so a test of "which one gets closed" would be a test
+ * of Playwright. The decision is the part with a wrong answer available, and this is it on its own.
+ *
+ * Least recently used first. That is the Bot that has been quiet longest, and closing it costs
+ * nothing but a relaunch: the profile is on disk, so its logins and its session survive.
+ */
+export function chooseEvictions(
+ running: Iterable<[string, { usedAt: number }]>,
+ 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 Bots' browsers have been sitting untouched.
+ *
+ * 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, { usedAt: number }]>,
+ idleTimeoutMs: number,
+ now: number,
+): string[] {
+ if (idleTimeoutMs <= 0) return [];
+ const cutoff = now - idleTimeoutMs;
+
+ return [...running]
+ .filter(([, entry]) => entry.usedAt <= cutoff)
+ .map(([botId]) => botId);
+}
+
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 +311,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 +342,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 +431,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..7c86c85d
--- /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/profiles";
+
+/**
+ * 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/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 (