From 0b1957b2fb90b7b6e376851bcc09f48939fbf3e0 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 11:57:26 -0300 Subject: [PATCH 01/73] Make every chart say where its figures came from A chart now carries a basis (read, given, own knowledge), the sources it was read from and an as-of date, and draws them under the plot. Figures the Bot recalled without looking up are marked as unchecked. The model has to give a basis; older conversations without one still render, with no footer. --- app/src/components/gallery/charts.tsx | 102 ++++++++++++++++++ app/tests/chart-sources.test.tsx | 92 ++++++++++++++++ server/src/components/headless.ts | 33 +++++- .../headless-components.integration.test.ts | 1 + server/tests/headless-components.test.ts | 7 +- 5 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 app/tests/chart-sources.test.tsx diff --git a/app/src/components/gallery/charts.tsx b/app/src/components/gallery/charts.tsx index 6a1039819f..4315cd9feb 100644 --- a/app/src/components/gallery/charts.tsx +++ b/app/src/components/gallery/charts.tsx @@ -10,6 +10,39 @@ const point = z.object({ value: z.number().describe("Its value"), }); +const source = z.object({ + title: z + .string() + .describe("The page, report or dataset, named the way a person would"), + url: z.string().optional().describe("Its address, when it has one"), +}); + +/** + * Where the figures came from, drawn under every chart. + * + * Required of the model, optional for the renderer. A chart is the most confident-looking thing a + * Bot can produce, and a number recalled from training looks exactly like one read this morning, so + * the Bot has to say which it is every time it draws one. Older conversations were drawn before the + * field existed, which is why the card still renders without it. + */ +const provenance = { + basis: z + .enum(["read", "given", "own-knowledge"]) + .describe( + "Where the figures come from. read: you read them with a tool in this conversation, and sources lists what you read. given: the person supplied them, in the conversation or a file. own-knowledge: you recalled them without looking them up; the chart then says they are unchecked. For figures that change over time, look them up before charting if you can", + ), + sources: z + .array(source) + .optional() + .describe("What you read the figures from. Required when basis is read"), + asOf: z + .string() + .optional() + .describe( + "When the figures are from, e.g. 'Q3 2026' or '24 Oct 2026'. Leave out for figures that do not date", + ), +}; + const common = { title: z.string().describe("A short title for the chart"), caption: z @@ -18,8 +51,64 @@ const common = { .describe( "One line under the title saying what the reader should take from it", ), + ...provenance, }; +type Provenance = Partial<{ + basis: "read" | "given" | "own-knowledge"; + sources: { title?: string; url?: string }[]; + asOf: string; +}>; + +/** Only web addresses become links. Anything else the model wrote is shown as text. */ +function isWebAddress(url: string | undefined): url is string { + return !!url && /^https?:\/\//i.test(url); +} + +/** The line under a chart that says where its figures came from. Nothing when nobody said. */ +function Sources({ basis, sources, asOf }: Provenance) { + const named = (sources ?? []).filter((entry) => entry?.title?.trim()); + const unchecked = basis === "own-knowledge"; + if (!unchecked && named.length === 0 && !asOf) return null; + return ( +

+ {unchecked ? ( + + From the Bot's own knowledge. Not checked against a source. + + ) : null} + {named.length > 0 ? ( + <> + {"Sources: "} + {named.map((entry, index) => ( + + {index > 0 ? ", " : null} + {isWebAddress(entry.url) ? ( + + {entry.title} + + ) : ( + entry.title + )} + + ))} + + ) : null} + {asOf ? ( + <> + {unchecked || named.length > 0 ? " · " : null} + {`As of ${asOf}`} + + ) : null} +

+ ); +} + /** Charts share a height so several in one answer do not stagger down the page. */ const HEIGHT = 180; @@ -66,6 +155,7 @@ export function BarChartCard({ title, caption, points: given, + ...origin }: Partial>) { const points = given ?? []; const max = Math.max(...points.map((entry) => entry.value ?? 0), 0); @@ -106,6 +196,7 @@ export function BarChartCard({ ))} )} + ); } @@ -121,6 +212,7 @@ export function PieChartCard({ title, caption, points: given, + ...origin }: Partial>) { const points = given ?? []; const size = 160; @@ -186,6 +278,7 @@ export function PieChartCard({ )} + ); } @@ -313,6 +406,7 @@ export function LineChartCard(args: Partial) { ) : ( )} + ); } @@ -332,6 +426,7 @@ export function AreaChartCard(args: Partial) { ) : ( )} + ); } @@ -356,6 +451,7 @@ export function ProgressChartCard({ title, caption, points: given, + ...origin }: Partial>) { const points = given ?? []; return ( @@ -391,6 +487,7 @@ export function ProgressChartCard({ })} )} + ); } @@ -406,6 +503,7 @@ export const GALLERY: GalleryComponent[] = [ parameters: BarChartProps, Component: BarChartCard as GalleryComponent["Component"], preview: { + basis: "given", title: "Revenue by team", caption: "Sales leads, and Engineering is closing the gap.", points: [ @@ -425,6 +523,7 @@ export const GALLERY: GalleryComponent[] = [ parameters: PieChartProps, Component: PieChartCard as GalleryComponent["Component"], preview: { + basis: "given", title: "Where the month went", points: [ { label: "Build", value: 48 }, @@ -443,6 +542,7 @@ export const GALLERY: GalleryComponent[] = [ parameters: LineChartProps, Component: LineChartCard as GalleryComponent["Component"], preview: { + basis: "given", title: "Signups", caption: "Six weeks, one release.", labels: ["W1", "W2", "W3", "W4", "W5", "W6"], @@ -459,6 +559,7 @@ export const GALLERY: GalleryComponent[] = [ parameters: AreaChartProps, Component: AreaChartCard as GalleryComponent["Component"], preview: { + basis: "given", title: "Storage used", caption: "Growing steadily since the migration.", labels: ["Jan", "Feb", "Mar", "Apr", "May"], @@ -475,6 +576,7 @@ export const GALLERY: GalleryComponent[] = [ parameters: ProgressChartProps, Component: ProgressChartCard as GalleryComponent["Component"], preview: { + basis: "given", title: "Migration to the new runtime", caption: "Two services left.", points: [ diff --git a/app/tests/chart-sources.test.tsx b/app/tests/chart-sources.test.tsx new file mode 100644 index 0000000000..3c38483ef3 --- /dev/null +++ b/app/tests/chart-sources.test.tsx @@ -0,0 +1,92 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, render } from "@testing-library/react"; +import { + AreaChartProps, + BarChartCard, + BarChartProps, + GALLERY, + LineChartCard, + LineChartProps, + PieChartProps, + ProgressChartProps, +} from "@/components/gallery/charts"; +import { settleReactWork } from "./settle-react-work"; + +beforeAll(() => GlobalRegistrator.register({ url: "http://localhost/" })); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const POINTS = [ + { label: "France", value: 3.1 }, + { label: "Japan", value: 4.2 }, +]; + +test("a chart drawn from what the Bot read names and links its sources", () => { + const view = render( + , + ); + const link = view.getByRole("link", { name: "IMF World Economic Outlook" }); + expect(link.getAttribute("href")).toBe("https://imf.org/weo"); + expect(view.getByText(/Annual report/)).toBeTruthy(); + expect(view.getByText(/As of 2025/)).toBeTruthy(); +}); + +test("figures from the Bot's own knowledge are marked as unchecked", () => { + const view = render( + , + ); + expect(view.getByText(/Not checked against a source/)).toBeTruthy(); +}); + +test("only web addresses become links", () => { + const view = render( + , + ); + expect(view.queryByRole("link")).toBeNull(); + expect(view.getByText(/Notes/)).toBeTruthy(); +}); + +test("a chart from before sources existed still renders, with no footer", () => { + const view = render(); + expect(view.getByText("GDP")).toBeTruthy(); + expect(view.queryByText(/Sources:|As of|Not checked/)).toBeNull(); +}); + +test("the model has to say where a chart's figures came from", () => { + expect( + BarChartProps.safeParse({ title: "GDP", points: POINTS }).success, + ).toBe(false); + const schemas = [ + BarChartProps, + PieChartProps, + LineChartProps, + AreaChartProps, + ProgressChartProps, + ]; + GALLERY.forEach((chart, index) => { + expect(schemas[index].safeParse(chart.preview).success).toBe(true); + }); +}); diff --git a/server/src/components/headless.ts b/server/src/components/headless.ts index 7ec33d2396..a33c3e8727 100644 --- a/server/src/components/headless.ts +++ b/server/src/components/headless.ts @@ -1,8 +1,8 @@ import { NOT_SHOWN, NOT_SHOWN_TAIL } from "@openbot/shared/component-markers"; -import { z } from "zod"; -import type { AuditInitiator, AuditSink } from "../audit"; import type { HeadlessTool } from "@openbot/toolkit/headless-tools"; import type { ComponentCatalog } from "@openbot/toolkit/ports"; +import { z } from "zod"; +import type { AuditInitiator, AuditSink } from "../audit"; import type { ComponentStore } from "./store"; /** @@ -32,6 +32,34 @@ const point = z.object({ value: z.number().describe("Its value"), }); +/** Where the figures came from. The same fields, and the same reason, as the app's charts. */ +const provenance = { + basis: z + .enum(["read", "given", "own-knowledge"]) + .describe( + "Where the figures come from. read: you read them with a tool in this conversation, and sources lists what you read. given: the person supplied them, in the conversation or a file. own-knowledge: you recalled them without looking them up; the chart then says they are unchecked. For figures that change over time, look them up before charting if you can", + ), + sources: z + .array( + z.object({ + title: z + .string() + .describe( + "The page, report or dataset, named the way a person would", + ), + url: z.string().optional().describe("Its address, when it has one"), + }), + ) + .optional() + .describe("What you read the figures from. Required when basis is read"), + asOf: z + .string() + .optional() + .describe( + "When the figures are from, e.g. 'Q3 2026' or '24 Oct 2026'. Leave out for figures that do not date", + ), +}; + const common = { title: z.string().describe("A short title for the chart"), caption: z @@ -40,6 +68,7 @@ const common = { .describe( "One line under the title saying what the reader should take from it", ), + ...provenance, }; const BarChartProps = z.object({ diff --git a/server/tests/headless-components.integration.test.ts b/server/tests/headless-components.integration.test.ts index 47917e80c9..c19af9a095 100644 --- a/server/tests/headless-components.integration.test.ts +++ b/server/tests/headless-components.integration.test.ts @@ -40,6 +40,7 @@ let addedComponents: string[] = []; const chartArgs = { title: "Stories on the front page by source", + basis: "read", caption: "Read from the page the routine opened.", points: [ { label: "github.com", value: 4 }, diff --git a/server/tests/headless-components.test.ts b/server/tests/headless-components.test.ts index 4173a247ef..61e6a870e3 100644 --- a/server/tests/headless-components.test.ts +++ b/server/tests/headless-components.test.ts @@ -149,6 +149,7 @@ describe("headless components mirror the compiled gallery", () => { ) as typeof import("../../app/node_modules/@types/react-dom/server"); const args = { title: "Open issues by team", + basis: "given", points: [ { label: "Platform", value: 12 }, { label: "Apps", value: 5 }, @@ -184,7 +185,11 @@ function fakeStore(decision: ComponentDecision): ComponentStore { describe("headless component tools", () => { const initiator = { kind: "routine" as const, id: "routine-1" }; - const valid = { title: "Revenue", points: [{ label: "Q1", value: 3 }] }; + const valid = { + title: "Revenue", + basis: "given", + points: [{ label: "Q1", value: 3 }], + }; test("a held chart is offered with its published description and records a confirmation", async () => { const tools = await createHeadlessComponentTools({ From d923fc82d1cb359e1e98625d59cc5065688b2d5f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 11:57:26 -0300 Subject: [PATCH 02/73] Tell Bots to look figures up before charting them --- shared/bot-prompt.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/shared/bot-prompt.ts b/shared/bot-prompt.ts index 306e97566b..a2dfcd05de 100644 --- a/shared/bot-prompt.ts +++ b/shared/bot-prompt.ts @@ -131,6 +131,11 @@ const PROVENANCE_GUIDANCE_LINES = [ "person names. Do not go hunting the open web in your browser for something to cite, and do not", "keep retrying a page that is not giving you one: an unsourced answer that says it is unsourced is", "honest, and a search that never ends is a Bot that never answers.", + "", + "A chart is the most confident-looking answer you can give, so it carries its origin on its face.", + "Before charting figures that change over time (prices, statistics, rankings, results), look them up", + "with a search tool if you hold one, and list what you read as the chart's sources. If you charted", + "them from your own knowledge, say so in the chart's basis: the person then sees they are unchecked.", ]; export const PROVENANCE_GUIDANCE = PROVENANCE_GUIDANCE_LINES.reduce( From 2e54810898d189aa97e8d9fa31c41f18c878b6c7 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:00:00 -0300 Subject: [PATCH 03/73] Let an admin turn on and give an app to a Bot in one press Adds POST /api/plugins/for/:agentId/apps/:app/approve, admin only. It files the request the app's state needs, as the admin, and approves it straight away through the same code the inbox's Approve runs: the approve body moved into a shared carryOut, and the filing checks into a shared requestable, so the one press can't grant more or check less than the two presses would. The trail reads as the two presses: app_access.requested, then app_access.approved marked direct. No refs, so only read-only actions are granted, as the inbox does. A press that can't be carried out withdraws the request it filed; one of the admin's own that was already waiting is put back as it was. --- server/src/plugins/route-groups/app-access.ts | 523 +++++++++++------- .../app-access-routes.integration.test.ts | 102 ++++ 2 files changed, 414 insertions(+), 211 deletions(-) diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts index a3c2364ccb..bb99c6e35c 100644 --- a/server/src/plugins/route-groups/app-access.ts +++ b/server/src/plugins/route-groups/app-access.ts @@ -1,7 +1,11 @@ import type { Context } from "hono"; import { type AppVariables, requireAdmin } from "../../auth/guards"; import { BROKERED_PROFILE, type BrokerProvider } from "../access"; -import type { Unavailable } from "../app-access"; +import type { + AppAccess, + AppAccessRequestKind, + Unavailable, +} from "../app-access"; import type { CatalogueEntry } from "../catalogue"; import type { AppAccessRequestRecord, @@ -152,6 +156,201 @@ export function mountAppAccessRoutes({ return context.json({ error: input.error }, input.status); }; + /** + * Carry out a request already claimed by this administrator: switch the app on, grant its tools to + * the Bot, or — for a team account, which only a consent screen can connect — confirm it has been + * connected. Answers with the response to send, and marks `done` once the change is carried out; + * one that was not leaves the claim for the caller to put back. + * + * SHARED BY THE INBOX'S APPROVE AND AN ADMINISTRATOR'S ONE PRESS ON A CARD, so the second can never + * grant more, check less or leave a thinner trail than the first. `direct` marks the trail row of + * the one press, whose request was filed and approved by the same person in the same breath. + */ + const carryOut = async ( + context: RouteContext, + request: AppAccessRequestRecord, + chosen: string[] | undefined, + done: { carriedOut: boolean }, + direct = false, + ): Promise => { + const deny = ( + reason: string, + error: string, + status: 400 | 404 | 409 | 502 | 503, + ) => + refused(context, { + app: request.appKey, + request: request.id, + bot: request.agentId, + kind: request.kind, + reason, + error, + status, + }); + + const inspect = () => + store.inspectApp({ + app: request.appKey, + agentId: request.agentId ?? "", + userId: null, + composioConfigured, + directory, + }); + + let inspected = await inspect(); + if (!inspected) + return deny( + "no_such_app", + `${request.appTitle} isn't available anymore.`, + 409, + ); + + let enabled = false; + if (request.kind !== "connect_shared" && inspected.unavailable) { + const title = inspected.access.displayName; + if (inspected.unavailable !== "absent") + return deny( + inspected.unavailable, + unavailableSentence(inspected.unavailable, title, activeProvider), + inspected.unavailable === "composio_unconfigured" ? 503 : 409, + ); + if (inspected.toolkit) { + if (!composio) + return deny( + "composio_unconfigured", + unavailableSentence("composio_unconfigured", title, activeProvider), + 503, + ); + const outcome = await enableComposioApp( + { store, composio, activeProvider, activeVendor }, + inspected.toolkit, + actorEmail(context), + ); + if (!outcome.ok) + return deny("enable_failed", outcome.error, outcome.status); + } else if (inspected.entry) { + const needed = adminInputFor(inspected.entry); + if (needed) + return deny( + "needs_admin_input", + `${title} needs ${needed}. Add it on its page under Plugins, then approve this request again.`, + 409, + ); + try { + await store.addServer({ + key: inspected.entry.key, + by: actorEmail(context), + }); + } catch (error) { + if ( + error instanceof CatalogueEntryUnknownError || + error instanceof CustomServerRefusedError + ) + return deny("enable_failed", error.message, 400); + if (isDeploymentFault(error)) + return deny("enable_failed", deploymentFaultSentence(error), 409); + throw error; + } + } else { + return deny( + "absent", + unavailableSentence("absent", title, activeProvider), + 409, + ); + } + enabled = true; + inspected = await inspect(); + if (!inspected || inspected.unavailable) + return deny( + "enable_failed", + `${title} was turned on but still can't be used. Open its page under Plugins to see why.`, + 409, + ); + } + + let granted: string[] = []; + if ( + request.kind !== "connect_shared" && + request.agentId && + !inspected.access.granted + ) { + const agentId = request.agentId; + const serverId = inspected.answeringId; + const name = inspected.access.displayName; + if (!(await store.agentIsRegistered(agentId)) || !serverId) + return deny("no_such_bot", "That Bot is no longer here.", 409); + const tools = await store.appTools(serverId); + const advertised = tools.map((tool) => tool.ref); + const refs = + chosen ?? tools.filter((tool) => tool.read).map((tool) => tool.ref); + const outside = refs.filter((ref) => !advertised.includes(ref)); + if (outside.length > 0) + return deny( + "refs_outside_app", + `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not among ${name}'s tools.`, + 400, + ); + if (advertised.length === 0) + return deny( + "no_tools", + `${name} has no tools to give yet. Refresh its tools on its page under Plugins, then approve this request again.`, + 409, + ); + if (refs.length === 0) + return deny( + "no_read_only_tools", + `None of ${name}'s actions are marked read-only, so approving here gives this Bot nothing. Choose its actions on ${name}'s page under Plugins, then approve this request again.`, + 409, + ); + for (const ref of refs) { + await store.grant("mcp", ref, agentId, actorEmail(context)); + } + const sharedDecision = await sharedGrantApproval( + { store, shared }, + refs[0] ?? "", + agentId, + undefined, + ); + if (sharedDecision !== "unreadable" && sharedDecision.approval && shared) + await shared.use.setApproval({ + botId: agentId, + serverId: sharedDecision.appId, + approval: sharedDecision.approval, + by: context.var.actor.id, + }); + granted = refs; + } + + if ( + request.kind === "connect_shared" && + inspected.access.account === "shared" && + !inspected.sharedUsable + ) + return deny( + "shared_not_connected", + `The shared ${inspected.access.displayName} account is not connected yet. Connect it on ${inspected.access.displayName}'s page under Plugins, then approve this request.`, + 409, + ); + + done.carriedOut = true; + const closed = request; + await store.recordAppAccessEvent({ + eventType: "app_access.approved", + targetId: closed.appKey, + actorUserId: context.var.actor.id, + payload: { + request: closed.id, + app: closed.appKey, + bot: closed.agentId, + kind: closed.kind, + enabled, + granted: granted.length, + ...(direct ? { direct: true } : {}), + }, + }); + return context.json({ request: closed, enabled, granted }); + }; + /** * Every work app this Bot holds, with what each one is for the person asking. * @@ -199,26 +398,21 @@ export function mountAppAccessRoutes({ }); /** - * Ask an administrator for access. The body names the Bot and the app; the kind is the server's. - * - * A SECOND PRESS IS THE FIRST REQUEST. One pending request per person, app, Bot and kind, so - * asking again answers with the one already waiting and `created: false`, and writes no second - * trail row. - * - * ONLY FOR AN APP SOMETHING VOUCHES FOR, AND ONLY SO MANY. Asking to enable a `composio-` id the - * directory does not list would put whatever the caller typed in front of an administrator as an - * app's name, for an approval that can only fail; it is answered as no such app, unless the Bot - * already holds a grant for it and the request is about putting back something it was given. And - * a person with {@link MAX_PENDING_REQUESTS} waiting is told to wait for those to be answered. + * The app a request would be about, or the refusal: the Bot and the app named, the Bot one this + * person may use, the app one something vouches for, and a state only an administrator can fix. + * What filing a request checks, and what an administrator's one press checks before it files and + * approves in the same breath — so neither can be pressed for something the other would refuse. */ - routes.post("/access-requests", requireUser, async (context) => { - const body = (await context.req.json().catch(() => null)) as { - agentId?: unknown; - app?: unknown; - } | null; - const agentId = - typeof body?.agentId === "string" ? body.agentId.trim() : ""; - const app = typeof body?.app === "string" ? body.app.trim() : ""; + const requestable = async ( + context: RouteContext, + rawAgentId: unknown, + rawApp: unknown, + ): Promise< + | Response + | { agentId: string; access: AppAccess; requestKind: AppAccessRequestKind } + > => { + const agentId = typeof rawAgentId === "string" ? rawAgentId.trim() : ""; + const app = typeof rawApp === "string" ? rawApp.trim() : ""; if (!agentId || !app) return context.json({ error: "Name the Bot and the app." }, 400); if (agentId.length > MAX_NAME || app.length > MAX_NAME) @@ -266,6 +460,30 @@ export function mountAppAccessRoutes({ : `${access.displayName} needs your own account, not an admin. Connect it yourself.`, status: 409, }); + return { agentId, access, requestKind: access.requestKind }; + }; + + /** + * Ask an administrator for access. The body names the Bot and the app; the kind is the server's. + * + * A SECOND PRESS IS THE FIRST REQUEST. One pending request per person, app, Bot and kind, so + * asking again answers with the one already waiting and `created: false`, and writes no second + * trail row. + * + * ONLY FOR AN APP SOMETHING VOUCHES FOR, AND ONLY SO MANY. Asking to enable a `composio-` id the + * directory does not list would put whatever the caller typed in front of an administrator as an + * app's name, for an approval that can only fail; it is answered as no such app, unless the Bot + * already holds a grant for it and the request is about putting back something it was given. And + * a person with {@link MAX_PENDING_REQUESTS} waiting is told to wait for those to be answered. + */ + routes.post("/access-requests", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + agentId?: unknown; + app?: unknown; + } | null; + const asked = await requestable(context, body?.agentId, body?.app); + if (asked instanceof Response) return asked; + const { agentId, access, requestKind } = asked; /* * The one already waiting is answered as itself before the cap is counted, so a person at the * cap pressing again on a request they filed is shown it, not refused. @@ -279,7 +497,7 @@ export function mountAppAccessRoutes({ app: access.key, bot: agentId, reason: "too_many_pending", - kind: access.requestKind, + kind: requestKind, error: `You already have ${MAX_PENDING_REQUESTS} requests waiting for an admin. Withdraw one, or wait for an answer, then ask again.`, status: 429, }); @@ -288,11 +506,80 @@ export function mountAppAccessRoutes({ agentId, appKey: access.key, appTitle: access.displayName, - kind: access.requestKind, + kind: requestKind, }); return context.json(filed, filed.created ? 201 : 200); }); + /** + * An administrator's one press on a card: file the request this app needs on this Bot, and + * approve it, as one step. Their own request, approved by themselves, so the trail reads exactly + * as the two presses would — `app_access.requested`, then `app_access.approved` marked `direct` — + * and nothing about it is a second way to grant. + * + * THE SAME CHECKS, THE SAME CHANGE. What may be asked for is {@link requestable}'s answer, and + * what approving does is {@link carryOut}'s; this route only joins them. No `refs`: the one press + * grants what the inbox's Approve grants without them, the app's read-only actions, and anything + * that sends or writes is still chosen action by action on the Bot's page. + * + * A PRESS THAT CANNOT BE CARRIED OUT LEAVES NOTHING WAITING. The request this press filed is + * withdrawn again, since an administrator's own inbox holding a request they could not carry out + * a moment ago is noise; the refusal's sentence says what is missing. A request of theirs that was + * already waiting is put back as it was. + */ + routes.post( + "/for/:agentId/apps/:app/approve", + requireUser, + async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + const asked = await requestable( + context, + context.req.param("agentId"), + context.req.param("app"), + ); + if (asked instanceof Response) return asked; + const { agentId, access, requestKind } = asked; + const by = context.var.actor.id; + const filed = await store.fileAppAccessRequest({ + requestedBy: by, + agentId, + appKey: access.key, + appTitle: access.displayName, + kind: requestKind, + }); + const claimed = await store.claimAppAccessRequest({ + id: filed.request.id, + by, + }); + if (!claimed) + return refused(context, { + app: access.key, + request: filed.request.id, + bot: agentId, + kind: requestKind, + reason: "already_resolved", + error: "That request has already been answered.", + status: 409, + }); + const done = { carriedOut: false }; + try { + return await carryOut(context, claimed, undefined, done, true); + } finally { + if (!done.carriedOut) { + await store.releaseAppAccessRequest({ id: claimed.id, by }); + if (filed.created) + await store.closeAppAccessRequest({ + id: claimed.id, + status: "cancelled", + by, + requestedBy: by, + }); + } + } + }, + ); + /** The person's own pending requests, newest first. */ routes.get("/access-requests/mine", requireUser, async (context) => context.json({ @@ -392,193 +679,6 @@ export function mountAppAccessRoutes({ return context.json({ error: "Tools must be a list of refs." }, 400); const chosen = body?.refs as string[] | undefined; - let carriedOut = false; - const approve = async (request: AppAccessRequestRecord) => { - const deny = ( - reason: string, - error: string, - status: 400 | 404 | 409 | 502 | 503, - ) => - refused(context, { - app: request.appKey, - request: request.id, - bot: request.agentId, - kind: request.kind, - reason, - error, - status, - }); - - const inspect = () => - store.inspectApp({ - app: request.appKey, - agentId: request.agentId ?? "", - userId: null, - composioConfigured, - directory, - }); - - let inspected = await inspect(); - if (!inspected) - return deny( - "no_such_app", - `${request.appTitle} isn't available anymore.`, - 409, - ); - - let enabled = false; - if (request.kind !== "connect_shared" && inspected.unavailable) { - const title = inspected.access.displayName; - if (inspected.unavailable !== "absent") - return deny( - inspected.unavailable, - unavailableSentence(inspected.unavailable, title, activeProvider), - inspected.unavailable === "composio_unconfigured" ? 503 : 409, - ); - if (inspected.toolkit) { - if (!composio) - return deny( - "composio_unconfigured", - unavailableSentence( - "composio_unconfigured", - title, - activeProvider, - ), - 503, - ); - const outcome = await enableComposioApp( - { store, composio, activeProvider, activeVendor }, - inspected.toolkit, - actorEmail(context), - ); - if (!outcome.ok) - return deny("enable_failed", outcome.error, outcome.status); - } else if (inspected.entry) { - const needed = adminInputFor(inspected.entry); - if (needed) - return deny( - "needs_admin_input", - `${title} needs ${needed}. Add it on its page under Plugins, then approve this request again.`, - 409, - ); - try { - await store.addServer({ - key: inspected.entry.key, - by: actorEmail(context), - }); - } catch (error) { - if ( - error instanceof CatalogueEntryUnknownError || - error instanceof CustomServerRefusedError - ) - return deny("enable_failed", error.message, 400); - if (isDeploymentFault(error)) - return deny("enable_failed", deploymentFaultSentence(error), 409); - throw error; - } - } else { - return deny( - "absent", - unavailableSentence("absent", title, activeProvider), - 409, - ); - } - enabled = true; - inspected = await inspect(); - if (!inspected || inspected.unavailable) - return deny( - "enable_failed", - `${title} was turned on but still can't be used. Open its page under Plugins to see why.`, - 409, - ); - } - - let granted: string[] = []; - if ( - request.kind !== "connect_shared" && - request.agentId && - !inspected.access.granted - ) { - const agentId = request.agentId; - const serverId = inspected.answeringId; - const name = inspected.access.displayName; - if (!(await store.agentIsRegistered(agentId)) || !serverId) - return deny("no_such_bot", "That Bot is no longer here.", 409); - const tools = await store.appTools(serverId); - const advertised = tools.map((tool) => tool.ref); - const refs = - chosen ?? tools.filter((tool) => tool.read).map((tool) => tool.ref); - const outside = refs.filter((ref) => !advertised.includes(ref)); - if (outside.length > 0) - return deny( - "refs_outside_app", - `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not among ${name}'s tools.`, - 400, - ); - if (advertised.length === 0) - return deny( - "no_tools", - `${name} has no tools to give yet. Refresh its tools on its page under Plugins, then approve this request again.`, - 409, - ); - if (refs.length === 0) - return deny( - "no_read_only_tools", - `None of ${name}'s actions are marked read-only, so approving here gives this Bot nothing. Choose its actions on ${name}'s page under Plugins, then approve this request again.`, - 409, - ); - for (const ref of refs) { - await store.grant("mcp", ref, agentId, actorEmail(context)); - } - const sharedDecision = await sharedGrantApproval( - { store, shared }, - refs[0] ?? "", - agentId, - undefined, - ); - if ( - sharedDecision !== "unreadable" && - sharedDecision.approval && - shared - ) - await shared.use.setApproval({ - botId: agentId, - serverId: sharedDecision.appId, - approval: sharedDecision.approval, - by: context.var.actor.id, - }); - granted = refs; - } - - if ( - request.kind === "connect_shared" && - inspected.access.account === "shared" && - !inspected.sharedUsable - ) - return deny( - "shared_not_connected", - `The shared ${inspected.access.displayName} account is not connected yet. Connect it on ${inspected.access.displayName}'s page under Plugins, then approve this request.`, - 409, - ); - - carriedOut = true; - const closed = request; - await store.recordAppAccessEvent({ - eventType: "app_access.approved", - targetId: closed.appKey, - actorUserId: context.var.actor.id, - payload: { - request: closed.id, - app: closed.appKey, - bot: closed.agentId, - kind: closed.kind, - enabled, - granted: granted.length, - }, - }); - return context.json({ request: closed, enabled, granted }); - }; - const claimed = await store.claimAppAccessRequest({ id, by: context.var.actor.id, @@ -595,10 +695,11 @@ export function mountAppAccessRoutes({ status: existing ? 409 : 404, }); } + const done = { carriedOut: false }; try { - return await approve(claimed); + return await carryOut(context, claimed, chosen, done); } finally { - if (!carriedOut) + if (!done.carriedOut) await store.releaseAppAccessRequest({ id, by: context.var.actor.id }); } }); diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts index 1fccc556c9..7870c44db7 100644 --- a/server/tests/app-access-routes.integration.test.ts +++ b/server/tests/app-access-routes.integration.test.ts @@ -998,6 +998,108 @@ describe("answering a request", () => { }); }); +describe("an administrator's one press on a card", () => { + const press = (app: string, as = admin, role: "admin" | "user" = "admin") => + appAs(as, role).request( + `/api/plugins/for/${bot}/apps/${encodeURIComponent(app)}/approve`, + { method: "POST" }, + ); + + test("turns an app on and gives it to the Bot, read-only, with the trail two presses leave", async () => { + const response = await press("Dropbox"); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + request: { + kind: "enable", + appKey: DROPBOX, + status: "approved", + requestedBy: admin, + resolvedBy: admin, + }, + enabled: true, + granted: [`${DROPBOX}/LIST_FILES`], + }); + expect(enabled).toEqual([slug("dropbox")]); + expect(eventsOf("app_access.requested")[0]?.payload).toMatchObject({ + app: DROPBOX, + kind: "enable", + }); + expect(eventsOf("app_access.approved")[0]?.payload).toMatchObject({ + app: DROPBOX, + kind: "enable", + enabled: true, + granted: 1, + direct: true, + }); + const { app } = await json<{ app: AppAccess }>( + await appAs(person).request(`/api/plugins/for/${bot}/apps/${DROPBOX}`), + ); + expect(app.state).toBe("not_connected"); + }); + + test("gives an app that is on to the Bot, and grants no send", async () => { + await seedApp({ + key: CALENDAR, + title: "Google Calendar", + tools: ["SEND", "LIST"], + effects: { LIST: "read", SEND: "write" }, + grant: false, + }); + const response = await press(CALENDAR); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + request: { kind: "grant", status: "approved" }, + enabled: false, + granted: [`${CALENDAR}/LIST`], + }); + }); + + test("only an administrator may press it, and nothing is filed for anybody else", async () => { + await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false }); + expect((await press(CALENDAR, person, "user")).status).toBe(403); + const rows = await database + .select() + .from(appAccessRequests) + .where(eq(appAccessRequests.appKey, CALENDAR)); + expect(rows).toHaveLength(0); + }); + + test("a state with nothing for an administrator to do is refused like a request", async () => { + await seedApp({ key: GMAIL, title: "Gmail" }); + const response = await press(GMAIL); + expect(response.status).toBe(409); + expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({ + reason: "nothing_to_request:not_connected", + }); + }); + + test("a press that cannot be carried out leaves no request waiting", async () => { + const response = await press("google-drive"); + expect(response.status).toBe(409); + expect((await json<{ error: string }>(response)).error).toContain( + "OAuth client", + ); + const rows = await database + .select({ status: appAccessRequests.status }) + .from(appAccessRequests) + .where(eq(appAccessRequests.requestedBy, admin)); + expect(rows.map((row) => row.status)).toEqual(["cancelled"]); + }); + + test("a request of theirs already waiting is put back as it was", async () => { + await appAs(admin, "admin").request( + "/api/plugins/access-requests", + post({ agentId: bot, app: "google-drive" }), + ); + expect((await press("google-drive")).status).toBe(409); + const rows = await database + .select({ status: appAccessRequests.status }) + .from(appAccessRequests) + .where(eq(appAccessRequests.requestedBy, admin)); + expect(rows.map((row) => row.status)).toEqual(["pending"]); + }); +}); + /* * THE SAME RULE ON AN ARCADE DEPLOYMENT. Which rows are brokered apps, which connections count and * which id an app nobody enabled would get all come from the store's broker, so a personal Arcade From 4e75075ebbf90b382c96a7ba1d9358c32ed447b5 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:00:26 -0300 Subject: [PATCH 04/73] Say on the app card's marker whether the app was blocked when the Bot asked A refusal is blocked by definition, but show_app_access also answers for an app that is ready, and its card can't tell later whether the app became ready since or was ready all along. The tool now writes blocked: true or false from the state it found, so a card drawn already ready can offer to carry on only where something actually changed. Older answers without the field offer nothing. --- server/src/plugins/app-access-tool.ts | 2 ++ server/tests/app-access-tool.test.ts | 1 + shared/connect-prompt.test.ts | 4 +++- shared/connect-prompt.ts | 14 ++++++++++++-- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts index c9c4ec25ab..3c9a60fdf9 100644 --- a/server/src/plugins/app-access-tool.ts +++ b/server/src/plugins/app-access-tool.ts @@ -87,6 +87,8 @@ export function appAccessTools(options: { title: access.displayName, agentId: options.botId, reason: "access", + // Lets a card drawn later tell "ready since" from "ready all along". + blocked: access.state !== "ready", message: toldTheModel(access), }); }, diff --git a/server/tests/app-access-tool.test.ts b/server/tests/app-access-tool.test.ts index 25e9c253c3..d3a31f7a10 100644 --- a/server/tests/app-access-tool.test.ts +++ b/server/tests/app-access-tool.test.ts @@ -96,6 +96,7 @@ describe("show_app_access", () => { title: "Gmail", agentId: "expenses", reason: "access", + blocked: state !== "ready", }); expect(prompt?.message).toMatch(said); expect(prompt?.message).toContain("Do not browse to its website"); diff --git a/shared/connect-prompt.test.ts b/shared/connect-prompt.test.ts index 336c53058a..9f06d0560a 100644 --- a/shared/connect-prompt.test.ts +++ b/shared/connect-prompt.test.ts @@ -69,6 +69,7 @@ describe("the Bot, the reason and the moment it carries", () => { agentId: "expenses", reason: "reconnect", at: "2026-10-20T12:00:00.000Z", + blocked: true, }); expect(readConnectPrompt(text)).toEqual({ serverId: "composio-gmail", @@ -77,6 +78,7 @@ describe("the Bot, the reason and the moment it carries", () => { agentId: "expenses", reason: "reconnect", at: "2026-10-20T12:00:00.000Z", + blocked: true, }); expect(connectAppRefusal({ serverId: "s", title: "T", message: "m" })).toBe( `${CONNECT_APP_MARKER}{"serverId":"s","title":"T"} m`, @@ -85,7 +87,7 @@ describe("the Bot, the reason and the moment it carries", () => { test("a field of the wrong shape is dropped, and the prompt still names its app", () => { const prompt = readConnectPrompt( - `${CONNECT_APP_MARKER}{"serverId":"composio-gmail","title":"Gmail","agentId":7,"reason":"grant me","at":"yesterday"} hello`, + `${CONNECT_APP_MARKER}{"serverId":"composio-gmail","title":"Gmail","agentId":7,"reason":"grant me","at":"yesterday","blocked":"yes"} hello`, ); expect(prompt).toEqual({ serverId: "composio-gmail", diff --git a/shared/connect-prompt.ts b/shared/connect-prompt.ts index 95bafbedb4..6f26154262 100644 --- a/shared/connect-prompt.ts +++ b/shared/connect-prompt.ts @@ -66,6 +66,13 @@ export type ConnectPrompt = { * and the card stops saying Reconnect. See `AppAccess.connectedAt`. */ at?: string; + /** + * True when the app could not be used at the moment the Bot asked: always so of a refusal, and + * written by `show_app_access` from the state it found. What lets a card that is drawn already + * `ready` tell "became ready since" — worth a Continue — from "was ready all along", which is not. + * Absent on a `show_app_access` answer from before it was written, which offers nothing. + */ + blocked?: boolean; }; /** @@ -84,13 +91,14 @@ export const SHOW_APP_ACCESS_TOOL = "show_app_access"; * the MODEL is told, and because a transcript that cannot parse the marker still reads correctly. */ export function connectAppRefusal(prompt: ConnectPrompt): string { - const { serverId, title, message, agentId, reason, at } = prompt; + const { serverId, title, message, agentId, reason, at, blocked } = prompt; return `${CONNECT_APP_MARKER}${JSON.stringify({ serverId, title, ...(agentId ? { agentId } : {}), ...(reason ? { reason } : {}), ...(at ? { at } : {}), + ...(blocked !== undefined ? { blocked } : {}), })} ${message}`; } @@ -125,12 +133,13 @@ export function readConnectPrompt( try { const parsed: unknown = JSON.parse(rest.slice(0, end + 1)); if (!parsed || typeof parsed !== "object") return null; - const { serverId, title, agentId, reason, at } = parsed as { + const { serverId, title, agentId, reason, at, blocked } = parsed as { serverId?: unknown; title?: unknown; agentId?: unknown; reason?: unknown; at?: unknown; + blocked?: unknown; }; if (typeof serverId !== "string" || typeof title !== "string") return null; if (!serverId || !title) return null; @@ -150,6 +159,7 @@ export function readConnectPrompt( ...(typeof at === "string" && !Number.isNaN(Date.parse(at)) ? { at } : {}), + ...(typeof blocked === "boolean" ? { blocked } : {}), }; } catch { return null; From 8c81ffdec8e580f3ac891da90710e94ef253f451 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:02:54 -0300 Subject: [PATCH 05/73] Give an admin the fix itself on an app card, not a request to themselves An admin looking at an app that is off, or that a Bot wasn't given, now gets one primary button: "Turn on and give it to " or "Give it to ". It calls the new one-press approve route, so the app is turned on and its read-only actions granted with the same checks and trail as the inbox. Send and write actions are still chosen on the Bot's page. Where one press can't do it (no broker key, a set-up left unfinished, a team account nobody connected) the admin gets a link to the app's admin page in a new tab instead. The card reads the app again when its tab has the focus back, so it follows whatever was done over there. --- .../components/channels/app-access-card.tsx | 10 +- .../components/plugins/app-access-action.tsx | 111 +++++++++++++++++- app/src/lib/app-access/mutations.ts | 26 ++++ app/tests/app-access-action.test.tsx | 91 +++++++++++++- app/tests/app-access-mutations.test.ts | 17 +++ 5 files changed, 251 insertions(+), 4 deletions(-) diff --git a/app/src/components/channels/app-access-card.tsx b/app/src/components/channels/app-access-card.tsx index c41443819d..1665a253cd 100644 --- a/app/src/components/channels/app-access-card.tsx +++ b/app/src/components/channels/app-access-card.tsx @@ -50,7 +50,15 @@ function LiveAppAccessCard({ agentId: string; prompt: ConnectPrompt; }) { - const live = useQuery(botAppQueryOptions(agentId, prompt.serverId)); + const live = useQuery({ + ...botAppQueryOptions(agentId, prompt.serverId), + /* + * Read again whenever this tab has the focus back. What changes the state usually happens in + * another tab — a consent screen, an admin's page for the app — and the person returning here + * is the moment the card should say what is true now. + */ + refetchOnWindowFocus: true, + }); /* * A read that failed falls back to what this card was before it read anything, where that was a * card at all: a person's own missing connection still gets its Connect button. The other reasons diff --git a/app/src/components/plugins/app-access-action.tsx b/app/src/components/plugins/app-access-action.tsx index 484bbfa3d3..42fe1cfbd5 100644 --- a/app/src/components/plugins/app-access-action.tsx +++ b/app/src/components/plugins/app-access-action.tsx @@ -1,13 +1,16 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab"; import { Button } from "@/components/ui/button"; +import { agentQueryOptions } from "@/lib/agents/queries"; import { + approveBotAppMutationOptions, cancelAppAccessRequestMutationOptions, requestAppAccessMutationOptions, } from "@/lib/app-access/mutations"; import { type AppAccess, appAccessKeys } from "@/lib/app-access/queries"; +import { currentUserQueryOptions } from "@/lib/auth/queries"; import { queryClient } from "@/query-client"; /** @@ -92,7 +95,8 @@ function disabledSentence(access: AppAccess): string { * that already has a row (which the connect route refuses until it is disconnected), go to the * app's own page in Settings instead, where the form and Disconnect are. * - the three an administrator fixes — Request access, which files the request the server decides - * the kind of, and then Requested with a way to withdraw it. + * the kind of, and then Requested with a way to withdraw it. Pressed by an administrator, it would + * be a request to themselves, so they get the fix itself instead: see `AdminAction`. */ export function AppAccessAction({ agentId, @@ -101,15 +105,118 @@ export function AppAccessAction({ agentId: string; access: AppAccess; }) { + /* + * WHO IS LOOKING decides only which button is drawn, never what is allowed: the server answers + * the one press 403 for anybody else, and `SharedAppNotice` already reads the role the same way. + */ + const me = useQuery(currentUserQueryOptions()).data; if (access.state === "ready" || access.state === "shared_not_approved") return null; if (access.state === "reconnect" && access.accountHolder === "owner") return null; if (access.state === "not_connected" || access.state === "reconnect") return ; + if (me?.role === "admin") + return ; return ; } +/** + * The step only an administrator can take, offered to the administrator as the step itself. + * + * ONE PRESS WHERE ONE PRESS CAN DO IT. An app nobody switched on, or one this Bot was never given, + * is switched on and given here through the inbox's own approval (`approveBotAppMutationOptions`): + * the same checks, the read-only actions only, and the same trail. Anything that sends or writes is + * still chosen on the Bot's page. + * + * A LINK WHERE IT CANNOT. A broker with no key, a set-up somebody left unfinished and a team account + * nobody connected all need something only typed or consented to on the app's admin page. That page + * opens in a new tab so the conversation stays where it is, and the card reads the app again when + * this tab has the focus back. + */ +function AdminAction({ + agentId, + access, +}: { + agentId: string; + access: AppAccess; +}) { + const bot = useQuery(agentQueryOptions(agentId)).data?.name ?? "this Bot"; + const approve = useMutation(approveBotAppMutationOptions(queryClient)); + const name = access.displayName; + + const oneClick = + access.state === "not_granted" || + (access.state === "disabled" && + (access.unavailable === null || access.unavailable === "absent")); + if (oneClick) { + const disabled = access.state === "disabled"; + const label = disabled + ? `Turn on and give it to ${bot}` + : `Give it to ${bot}`; + // The card names the app beside the button; on its own, the button has to. + const spoken = disabled + ? `Turn on ${name} and give it to ${bot}` + : `Give ${name} to ${bot}`; + return ( +
+ {approve.error ? ( +

+ {approve.error.message} +

+ ) : null} + +
+ ); + } + + const page = + access.state === "disabled" && + access.unavailable === "composio_unconfigured" + ? access.provider === "arcade" + ? "arcade" + : "composio" + : access.key; + const [label, spoken] = + access.state !== "shared_not_connected" + ? ["Set it up", `Set up ${name}`] + : access.requestKind + ? ["Connect the team account", `Connect the team ${name} account`] + : ["Reconnect the team account", `Reconnect the team ${name} account`]; + return ( + + ); +} + /** The person's own account: connect it, or reconnect it. */ function ConnectAction({ access }: { access: AppAccess }) { const label = access.state === "reconnect" ? "Reconnect" : "Connect"; diff --git a/app/src/lib/app-access/mutations.ts b/app/src/lib/app-access/mutations.ts index 31131a5df2..b923542bdc 100644 --- a/app/src/lib/app-access/mutations.ts +++ b/app/src/lib/app-access/mutations.ts @@ -85,6 +85,32 @@ export function approveAppAccessRequestMutationOptions( }); } +/** + * An administrator's one press: file the request this app needs on this Bot and approve it in the + * same step, through the inbox's own approval — the app switched on and its read-only actions + * granted, with the trail two presses would leave. Answers with what changed, or the sentence for + * what is missing (an OAuth client, a broker key) when it cannot be done from here. + */ +export function approveBotAppMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + agentId: string; + app: string; + }): Promise<{ + request: AppAccessRequest; + enabled: boolean; + granted: string[]; + }> => { + const response = await client( + `/api/plugins/for/${encodeURIComponent(variables.agentId)}/apps/${encodeURIComponent(variables.app)}/approve`, + { method: "POST", fallback: "That app could not be turned on." }, + ); + return response.json(); + }, + onSettled: () => refresh(queryClient), + }); +} + /** Dismiss a request without changing anything else. */ export function dismissAppAccessRequestMutationOptions( queryClient: QueryClient, diff --git a/app/tests/app-access-action.test.tsx b/app/tests/app-access-action.test.tsx index 1eb3e7a327..89fb1d0a41 100644 --- a/app/tests/app-access-action.test.tsx +++ b/app/tests/app-access-action.test.tsx @@ -56,6 +56,7 @@ type Sent = { method: string; url: string; body: unknown }; function mounted( access: AppAccess, answer?: { status: number; body: unknown }, + options: { admin?: boolean } = {}, ) { const sent: Sent[] = []; const opened: string[] = []; @@ -79,6 +80,10 @@ function mounted( url, body: init?.body ? JSON.parse(String(init.body)) : undefined, }); + if (options.admin && url === "/api/me") + return Response.json({ user: { id: "usr_admin", role: "admin" } }); + if (options.admin && url === "/api/agents/expenses") + return Response.json({ agent: { id: "expenses", name: "Expenses" } }); if (answer && method !== "GET") return Response.json(answer.body, { status: answer.status }); if (url.endsWith("/connect?returnTo=settings")) @@ -112,8 +117,13 @@ function mounted( path: "/connections/$key", component: () => null, }); + const admin = createRoute({ + getParentRoute: () => root, + path: "/admin/plugins/$key", + component: () => null, + }); const router = createRouter({ - routeTree: root.addChildren([page, settings]), + routeTree: root.addChildren([page, settings, admin]), history: createMemoryHistory({ initialEntries: ["/"] }), }); const view = render( @@ -442,3 +452,82 @@ test("the focus follows the press: Request access hands it to Withdraw, and With await view.findByRole("button", { name: "Request access to Gmail" }), ); }); + +test.each([ + [ + "disabled", + "Turn on and give it to Expenses", + "Turn on Gmail and give it to Expenses", + ], + ["not_granted", "Give it to Expenses", "Give Gmail to Expenses"], +] as const)( + "an admin looking at %s gets the fix itself, in one press, never a request to themselves", + async (state, label, spoken) => { + const { view, sent } = mounted( + appAccess({ + state, + unavailable: state === "disabled" ? "absent" : null, + requestKind: state === "disabled" ? "enable" : "grant", + }), + undefined, + { admin: true }, + ); + const button = await view.findByRole("button", { name: spoken }); + expect(button.textContent).toBe(label); + expect( + view.queryByRole("button", { name: "Request access to Gmail" }), + ).toBeNull(); + fireEvent.click(button); + await waitFor(() => + expect(sent.find((call) => call.method === "POST")).toEqual({ + method: "POST", + url: "/api/plugins/for/expenses/apps/composio-gmail/approve", + body: undefined, + }), + ); + }, +); + +test("an admin's press that cannot be carried out says what is missing", async () => { + const { view } = mounted( + appAccess({ state: "not_granted", requestKind: "grant" }), + { status: 409, body: { error: "Gmail has no tools to give yet." } }, + { admin: true }, + ); + fireEvent.click( + await view.findByRole("button", { name: "Give Gmail to Expenses" }), + ); + expect((await view.findByRole("alert")).textContent).toBe( + "Gmail has no tools to give yet.", + ); +}); + +test.each([ + [ + { state: "shared_not_connected", requestKind: "connect_shared" }, + "Connect the team Gmail account", + "/admin/plugins/composio-gmail", + ], + [ + { state: "disabled", unavailable: "token_missing", requestKind: "enable" }, + "Set up Gmail", + "/admin/plugins/composio-gmail", + ], + [ + { + state: "disabled", + unavailable: "composio_unconfigured", + requestKind: "enable", + }, + "Set up Gmail", + "/admin/plugins/composio", + ], +] as const)( + "an admin gets the page that fixes what one press cannot, in a new tab", + async (overrides, name, href) => { + const { view } = mounted(appAccess(overrides), undefined, { admin: true }); + const link = await view.findByRole("button", { name }); + expect(link.getAttribute("href")).toBe(href); + expect(link.getAttribute("target")).toBe("_blank"); + }, +); diff --git a/app/tests/app-access-mutations.test.ts b/app/tests/app-access-mutations.test.ts index 91f4a06ffd..30e0292890 100644 --- a/app/tests/app-access-mutations.test.ts +++ b/app/tests/app-access-mutations.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from "bun:test"; import { MutationObserver, QueryClient } from "@tanstack/react-query"; import { approveAppAccessRequestMutationOptions, + approveBotAppMutationOptions, cancelAppAccessRequestMutationOptions, dismissAppAccessRequestMutationOptions, requestAppAccessMutationOptions, @@ -106,6 +107,22 @@ test("approving sends refs only when given", async () => { expect(sent[0]).not.toHaveProperty("body"); }); +test("an admin's one press names the Bot and the app in the path, and sends no refs", async () => { + const sent = answering(200, { request: {}, enabled: true, granted: [] }); + const { asked } = await run(approveBotAppMutationOptions, { + agentId: "bot 1", + app: "composio-gmail", + }); + expect(sent).toEqual([ + { + url: "/api/plugins/for/bot%201/apps/composio-gmail/approve", + method: "POST", + }, + ]); + expect(sent[0]).not.toHaveProperty("body"); + expect(asked).toEqual([{ queryKey: pluginKeys.all }]); +}); + test("a refused write still refreshes every plugin screen", async () => { for (const build of [ requestAppAccessMutationOptions, From e84b84bd9808a869c3f92af9c86b5e873d260067 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:07:01 -0300 Subject: [PATCH 06/73] Carry the task on by itself once a Bot's app card says the app is ready When the person finishes connecting in the other tab, or an admin turns the app on and gives it to the Bot, the card used to say "Ask again and the Bot can use it". Now the card that sees its app go from blocked to ready sends one message on the person's behalf, "Gmail is ready now. Please carry on.", through the same ask any transcript card uses. Why a message and not handoff-resume: that resumes a browser tool call that is still open. An app card is drawn from a call that already answered, and its turn has ended, so there is nothing open to answer. The next turn is what carries the task on. A visible message says why the Bot started again, and the Bot gets the whole conversation with it. Guards: - Only while the card is the latest thing: the person has said nothing since it, has nothing queued, and the Bot isn't working. The transcript passes this down with the tool call id. - Only once per card, across re-renders, remounts and tabs, by a mark in localStorage keyed by the tool call id, set before sending. - Only automatically where the card saw the change. A card drawn already ready (an admin approved while the conversation was closed) offers a Continue button. For show_app_access it does so only if the marker says the app was blocked when the Bot asked. --- .../components/channels/app-access-card.tsx | 50 +++++- .../components/channels/chat-transcript.tsx | 31 +++- .../components/channels/connect-app-card.tsx | 27 ++- app/src/lib/copilot/carry-on.ts | 119 +++++++++++++ app/tests/app-access-card.test.tsx | 161 +++++++++++++++++- app/tests/connect-app-card.test.tsx | 44 ++++- 6 files changed, 408 insertions(+), 24 deletions(-) create mode 100644 app/src/lib/copilot/carry-on.ts diff --git a/app/src/components/channels/app-access-card.tsx b/app/src/components/channels/app-access-card.tsx index 1665a253cd..e0eff73e31 100644 --- a/app/src/components/channels/app-access-card.tsx +++ b/app/src/components/channels/app-access-card.tsx @@ -4,8 +4,10 @@ import { appAccessSentence, } from "@/components/plugins/app-access-action"; import { PluginLogo } from "@/components/plugins/plugin-logo"; +import { Button } from "@/components/ui/button"; import { type AppAccess, botAppQueryOptions } from "@/lib/app-access/queries"; import { useDeclaredBotId } from "@/lib/copilot/active-bot"; +import { type CardPlace, useCarryOn } from "@/lib/copilot/carry-on"; import type { ConnectPrompt } from "@openbot/shared/connect-prompt"; import { ConnectAppCard } from "./connect-app-card"; @@ -31,23 +33,36 @@ import { ConnectAppCard } from "./connect-app-card"; * `reconnect` or `shared` refusal over a live `ready` is drawn as the refusal says — unless the * account was connected after the refusal, which is the reconnection. Anything else live is newer * than the refusal and is drawn as it is. + * + * AND THEN THE TASK CARRIES ON. Once the app is ready — connected in the other tab, switched on and + * given by an admin — the card sends the conversation on by itself, or offers Continue where it was + * not watching when that happened. `place` is where the card sits in its conversation, without + * which (a card drawn outside one) it only says the app is ready. See `lib/copilot/carry-on.ts`. */ -export function AppAccessCard({ prompt }: { prompt: ConnectPrompt }) { +export function AppAccessCard({ + prompt, + place, +}: { + prompt: ConnectPrompt; + place?: CardPlace; +}) { const declared = useDeclaredBotId(); const agentId = prompt.agentId ?? declared; /* * A refusal from before refusals named their Bot, on a surface that declared none: there is no Bot * to ask about, so the card it was drawn as then is the card it is drawn as now. */ - if (!agentId) return ; - return ; + if (!agentId) return ; + return ; } function LiveAppAccessCard({ agentId, + place, prompt, }: { agentId: string; + place: CardPlace | undefined; prompt: ConnectPrompt; }) { const live = useQuery({ @@ -59,15 +74,22 @@ function LiveAppAccessCard({ */ refetchOnWindowFocus: true, }); + const access = live.data ? asRefused(live.data, prompt) : null; + const name = access?.displayName ?? prompt.title; + const { carried, offer } = useCarryOn({ + place, + ready: access ? access.state === "ready" : null, + // A refusal was blocked by definition; `show_app_access` says what it found. + blockedWhenAsked: prompt.reason !== "access" || prompt.blocked === true, + app: name, + }); /* * A read that failed falls back to what this card was before it read anything, where that was a * card at all: a person's own missing connection still gets its Connect button. The other reasons * had only ever been a sentence, and the sentence is what is left. */ if (live.error && (prompt.reason ?? "connect") === "connect") - return ; - const access = live.data ? asRefused(live.data, prompt) : null; - const name = access?.displayName ?? prompt.title; + return ; return (
- {access.state === "ready" && prompt.reason !== "access" - ? `${name} is ready now. Ask again and the Bot can use it.` - : refusedSentence(access, prompt)} + {access.state !== "ready" + ? refusedSentence(access, prompt) + : carried || offer + ? `${name} is ready now.` + : prompt.reason !== "access" + ? `${name} is ready now. Ask again and the Bot can use it.` + : refusedSentence(access, prompt)}

) : (

@@ -103,6 +129,12 @@ function LiveAppAccessCard({

+ ) : offer ? ( +
+ +
) : null}
); diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index b439b5c5d7..3b14c80e26 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -59,6 +59,7 @@ import { toolResultFailed, } from "@/lib/plugins/tool-result"; import { readTeamBotConsent } from "@/lib/team-bots"; +import type { CardPlace } from "@/lib/copilot/carry-on"; import { readConnectPrompt, SHOW_APP_ACCESS_TOOL, @@ -1301,12 +1302,15 @@ const TranscriptToolCall = memo(function TranscriptToolCall({ name, args, result, + latest, }: { delay: number; toolCallId: string; name: string; args: string; result?: string; + /** Nothing said or queued since this call, and the Bot is not working. See `CardPlace`. */ + latest: boolean; }) { const renderToolCall = useRenderToolCall(); const toolCall = useMemo( @@ -1344,7 +1348,13 @@ const TranscriptToolCall = memo(function TranscriptToolCall({ * What was called, shimmering until its result arrives, and then the server's own words * drawn the way a Bot's prose is drawn. */} - {drawn ?? } + {drawn ?? ( + + )} ); @@ -1361,9 +1371,12 @@ const TranscriptToolCall = memo(function TranscriptToolCall({ export function ServerToolLine({ name, result, + place, }: { name: string; result?: string; + /** Where this call sits in the conversation, for an app card that may carry the task on. */ + place?: CardPlace; }) { const running = result === undefined; const { label, detail } = readToolName(name, running); @@ -1398,7 +1411,7 @@ export function ServerToolLine({ : name === SHOW_APP_ACCESS_TOOL ? readConnectPrompt(answer) : null; - if (card) return ; + if (card) return ; /* * THAT TOOL'S OTHER ANSWERS ARE FOR THE MODEL. An app nobody can name comes back as plain text * telling the Bot to say so plainly and not to browse to it — an instruction, which read by the @@ -1602,6 +1615,19 @@ export function ChatTranscript({ )?.id ?? null; useSmoothSendScroll(viewportRef, newestUserMessageId); + /* + * WHERE THE CONVERSATION HAS GOT TO, for an app card that may carry its task on once the app is + * ready (see `lib/copilot/carry-on.ts`). A card is the latest thing while the person has said + * nothing since it, has nothing queued, and the Bot is not working: past that, the conversation + * has moved on, and an older task is not picked up again underneath it. + */ + const lastSaid = items.findLastIndex( + (item) => + (item.kind === "text" && isPersonSentMessage(item.role, item.text)) || + item.kind === "attachments", + ); + const settled = !busy && queued.length === 0; + /* One rule for both kinds of row — see `anchorRowIds`, which is where it is written down. */ const anchorRows = anchorRowIds(items); @@ -1723,6 +1749,7 @@ export function ChatTranscript({ lastSaid} name={item.toolCall.function.name} result={item.result} toolCallId={item.toolCall.id} diff --git a/app/src/components/channels/connect-app-card.tsx b/app/src/components/channels/connect-app-card.tsx index d89372fa4b..241519654e 100644 --- a/app/src/components/channels/connect-app-card.tsx +++ b/app/src/components/channels/connect-app-card.tsx @@ -3,6 +3,7 @@ import { Link } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab"; import { Button } from "@/components/ui/button"; +import { type CardPlace, useCarryOn } from "@/lib/copilot/carry-on"; import { connectionsQueryOptions, personalConnections, @@ -26,15 +27,19 @@ import { queryClient } from "@/query-client"; * draws it from the app's live state for this person on that Bot. This one is drawn only where no * Bot can be named — a refusal written before refusals carried one, on a surface that declared none * — and it can only offer Connect, which is all such a refusal ever meant. + * + * Once connected it carries the conversation on, as `AppAccessCard` does: see `useCarryOn`. */ export function ConnectAppCard({ serverId, title, message, + place, }: { serverId: string; title: string; message: string; + place?: CardPlace | undefined; }) { const { open: openTab, @@ -59,6 +64,13 @@ export function ConnectAppCard({ const connected = personalConnections( connections.data?.connections ?? [], ).some((row) => row.serverId === serverId && row.revoked !== true); + // Every card drawn here is a refusal, so the app was blocked when the Bot asked. + const { carried, offer } = useCarryOn({ + place, + ready: connections.data ? connected : null, + blockedWhenAsked: true, + app: title, + }); /* * The grants a Bot holds are read per conversation, so the turn that follows has to be asked @@ -85,9 +97,18 @@ export function ConnectAppCard({

{message}

{connected ? ( -

- {title} is connected. Ask again and the Bot can use it. -

+
+

+ {carried || offer + ? `${title} is connected.` + : `${title} is connected. Ask again and the Bot can use it.`} +

+ {offer ? ( + + ) : null} +
) : needsKey ? (

{title} needs a key from you.{" "} diff --git a/app/src/lib/copilot/carry-on.ts b/app/src/lib/copilot/carry-on.ts new file mode 100644 index 0000000000..f01fe4360d --- /dev/null +++ b/app/src/lib/copilot/carry-on.ts @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from "react"; +import { useConversation } from "./conversation"; + +/** + * Carrying on with the task an app card interrupted, once the app is ready, without the person + * having to type their question again. + * + * WHY NOT THE HANDOFF RESUME. `handoff-resume.ts` answers a browser tool call that is still open: + * the turn is paused inside the call, and resuming hands that call its result. An app card is drawn + * from a call that has already answered — a refusal, or `show_app_access` — and the turn it was in + * has ended with the Bot saying what it needs. There is nothing open to answer. What carries the + * task on is simply the next turn, so that is what this sends: one short message, as the person, + * through the same `ask` a button inside any transcript card uses. + * + * A MESSAGE RATHER THAN A HIDDEN RUN, deliberately. It is what the person would have typed, it + * shows in the transcript so nobody wonders why the Bot started again, and it reaches the Bot with + * the whole conversation behind it, so the Bot picks up the task it had stopped on. A run with no + * new message would have the model guessing why it was woken. + * + * ONCE, AND ONLY WHILE NOTHING ELSE HAS HAPPENED. The card counts as the latest thing only while + * the person has said nothing since it, has nothing queued, and the Bot is not working (see + * `ChatTranscript`). A person who moved on is not dragged back to an older task. And it is sent + * once per card, across re-renders, remounts and other tabs on this browser, by a mark kept in + * `localStorage` under the tool call the card was drawn for, set before the message is sent. + * + * SEEN, OR OFFERED. A card that watched the app go from blocked to ready carries on by itself: the + * person was here, and the change is what they were waiting for. A card drawn already ready, in a + * conversation opened after the change (an admin approved overnight), cannot know the person still + * wants the task done, so it offers Continue and leaves it to them. + */ + +/** Where an app card sits in its conversation, as the transcript knows it. */ +export type CardPlace = { + /** The tool call the card was drawn for: what "only once" is counted against. */ + toolCallId: string; + /** Nothing said or queued since the card, and the Bot is not working. */ + latest: boolean; +}; + +const MARK = "openbot:carried-on:"; + +/** Marks kept here as well, for a browser that refuses `localStorage` (a private window, a policy). */ +const carriedHere = new Set(); + +function hasCarriedOn(toolCallId: string): boolean { + if (carriedHere.has(toolCallId)) return true; + try { + return window.localStorage.getItem(MARK + toolCallId) !== null; + } catch { + return false; + } +} + +/** Mark this card as carried on. False when something already had: this caller must not send. */ +function markCarriedOn(toolCallId: string): boolean { + if (hasCarriedOn(toolCallId)) return false; + carriedHere.add(toolCallId); + try { + window.localStorage.setItem(MARK + toolCallId, new Date().toISOString()); + } catch { + // The in-memory mark still holds for this tab. + } + return true; +} + +/** What is sent as the person when the task carries on. Short, and true whoever made it ready. */ +export function carryOnMessage(app: string): string { + return `${app} is ready now. Please carry on.`; +} + +/** + * Carry on when this card's app becomes ready, or offer to. + * + * `ready` is null until the card knows; only a known `false` counts as having seen the app blocked. + * `blockedWhenAsked` says whether the app was out of reach when the Bot asked — what lets a card + * drawn already ready offer Continue only where something changed since. Returns `carried` once + * this card has carried on (here or in another tab), and `offer` when a Continue is worth drawing. + */ +export function useCarryOn({ + place, + ready, + blockedWhenAsked, + app, +}: { + place: CardPlace | undefined; + ready: boolean | null; + blockedWhenAsked: boolean; + app: string; +}): { carried: boolean; offer: (() => void) | null } { + const ask = useConversation()?.ask; + const sawBlocked = useRef(false); + // Only re-renders this card once it has sent, so `carried` is read again. + const [, rerender] = useState(0); + useEffect(() => { + if (ready === false) sawBlocked.current = true; + }, [ready]); + + const toolCallId = place?.toolCallId; + const carried = toolCallId ? hasCarriedOn(toolCallId) : false; + const can = Boolean(ask && toolCallId && place?.latest && ready === true); + + const send = () => { + if (!ask || !toolCallId || !markCarriedOn(toolCallId)) return; + ask(carryOnMessage(app)); + rerender((count) => count + 1); + }; + const sendRef = useRef(send); + sendRef.current = send; + + useEffect(() => { + if (can && sawBlocked.current) sendRef.current(); + }, [can]); + + return { + carried, + offer: + can && !carried && !sawBlocked.current && blockedWhenAsked ? send : null, + }; +} diff --git a/app/tests/app-access-card.test.tsx b/app/tests/app-access-card.test.tsx index 5530127fac..975bd4da3b 100644 --- a/app/tests/app-access-card.test.tsx +++ b/app/tests/app-access-card.test.tsx @@ -1,4 +1,11 @@ -import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { @@ -9,12 +16,20 @@ import { Outlet, RouterProvider, } from "@tanstack/react-router"; -import { cleanup, render, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; import type { ReactNode } from "react"; import { AppAccessCard } from "@/components/channels/app-access-card"; import { ServerToolLine } from "@/components/channels/chat-transcript"; import type { AppAccess } from "@/lib/app-access/queries"; import { ActiveBotProvider, useActiveBot } from "@/lib/copilot/active-bot"; +import { carryOnMessage } from "@/lib/copilot/carry-on"; +import { ConversationProvider } from "@/lib/copilot/conversation"; import { REFUSAL_MARKER } from "@/lib/plugins/tool-result"; import { type ConnectPrompt, @@ -83,11 +98,12 @@ function mount(children: ReactNode) { routeTree: root.addChildren([page, settings]), history: createMemoryHistory({ initialEntries: ["/"] }), }); - return render( + const view = render( , ); + return Object.assign(view, { client }); } const prompt = (overrides: Partial = {}): ConnectPrompt => ({ @@ -388,3 +404,142 @@ test("show_app_access about an app nobody can name draws the line, never its wor // Nothing behind a disclosure either: the line is not expandable. expect(view.container.querySelector("details")).toBeNull(); }); + +/* + * CARRYING ON. Once the app is ready the conversation goes on by itself — one message, as the + * person, through the conversation's `ask` — but only from the card that is still the latest thing, + * only once, and only where the card saw the change; a card drawn already ready offers Continue. + */ +describe("carrying the task on once the app is ready", () => { + let calls = 0; + const nextId = () => { + calls += 1; + return `call_${calls}_${Math.random().toString(36).slice(2)}`; + }; + + /** Answers the live read with whatever `state.live` is now, so a test can change it. */ + function servingNow(state: { live: AppAccess }) { + global.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/apps/")) return Response.json({ app: state.live }); + if (url.includes("/api/plugins/connections")) + return Response.json({ connections: [], redirectUri: null }); + return Response.json({}); + }) as unknown as typeof fetch; + } + + function inConversation( + children: ReactNode, + ): ReturnType & { asked: string[] } { + const asked: string[] = []; + const view = mount( + asked.push(text)}> + {children} + , + ); + return Object.assign(view, { asked }); + } + + test("a card that sees the app become ready carries on, once", async () => { + const state = { live: appAccess({ state: "not_connected" }) }; + servingNow(state); + const place = { toolCallId: nextId(), latest: true }; + const view = inConversation( + , + ); + expect((await card(view)).getAttribute("data-state")).toBe("not_connected"); + state.live = appAccess({ state: "ready" }); + await act(() => view.client.invalidateQueries()); + await waitFor(() => expect(view.asked).toEqual([carryOnMessage("Gmail")])); + expect((await card(view)).textContent).toContain("Gmail is ready now."); + expect(view.container.textContent).not.toContain("Ask again"); + // Read again and drawn again: still the one message. + await act(() => view.client.invalidateQueries()); + await settleReactWork(); + expect(view.asked).toHaveLength(1); + expect(view.queryByRole("button", { name: "Continue" })).toBeNull(); + }); + + test("a card the conversation has moved on from only says the app is ready", async () => { + const state = { live: appAccess({ state: "not_connected" }) }; + servingNow(state); + const view = inConversation( + , + ); + await card(view); + state.live = appAccess({ state: "ready" }); + await act(() => view.client.invalidateQueries()); + await waitFor(async () => + expect((await card(view)).getAttribute("data-state")).toBe("ready"), + ); + await settleReactWork(); + expect(view.asked).toEqual([]); + expect((await card(view)).textContent).toContain( + "Gmail is ready now. Ask again and the Bot can use it.", + ); + }); + + test("a card drawn already ready offers Continue, which carries on once", async () => { + servingNow({ live: appAccess({ state: "ready" }) }); + const place = { toolCallId: nextId(), latest: true }; + const view = inConversation( + , + ); + fireEvent.click(await view.findByRole("button", { name: "Continue" })); + expect(view.asked).toEqual([carryOnMessage("Gmail")]); + await waitFor(() => + expect(view.queryByRole("button", { name: "Continue" })).toBeNull(), + ); + cleanup(); + // The same card drawn again, here or in another tab, has nothing left to offer. + const again = inConversation( + , + ); + expect((await card(again)).textContent).toContain("Gmail is ready now."); + expect(again.queryByRole("button", { name: "Continue" })).toBeNull(); + expect(again.asked).toEqual([]); + }); + + test.each([ + [true, true], + [false, false], + [undefined, false], + ])( + "show_app_access drawn ready offers Continue only if the app was blocked when asked (blocked: %p)", + async (blocked, offered) => { + servingNow({ live: appAccess({ state: "ready" }) }); + const view = inConversation( + , + ); + await card(view); + await settleReactWork(); + expect(view.queryByRole("button", { name: "Continue" }) !== null).toBe( + offered, + ); + expect(view.asked).toEqual([]); + }, + ); + + test("with no conversation around it, the card only says the app is ready", async () => { + servingNow({ live: appAccess({ state: "ready" }) }); + const view = mount( + , + ); + expect((await card(view)).textContent).toContain( + "Ask again and the Bot can use it.", + ); + expect(view.queryByRole("button", { name: "Continue" })).toBeNull(); + }); +}); diff --git a/app/tests/connect-app-card.test.tsx b/app/tests/connect-app-card.test.tsx index 93e3fd5872..16d37fd6eb 100644 --- a/app/tests/connect-app-card.test.tsx +++ b/app/tests/connect-app-card.test.tsx @@ -11,6 +11,8 @@ import { } from "@tanstack/react-router"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { ConnectAppCard } from "@/components/channels/connect-app-card"; +import { carryOnMessage } from "@/lib/copilot/carry-on"; +import { ConversationProvider } from "@/lib/copilot/conversation"; import { settleReactWork } from "./settle-react-work"; /** @@ -61,6 +63,8 @@ function mounted( answers?: "url" | "fields" | "failure" | "connected"; /** False stands in for a browser that refused the tab, which answers null. */ popupsAllowed?: boolean; + /** Drawn inside a conversation, as the latest thing in it: what the card sends is kept here. */ + asked?: string[]; } = {}, ) { const { connections = [], answers = "url", popupsAllowed = true } = options; @@ -122,17 +126,30 @@ function mounted( const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + // One card, one tool call, however often the route draws it. + const toolCallId = `call_${Math.random().toString(36).slice(2)}`; const root = createRootRoute({ component: Outlet }); const card = createRoute({ getParentRoute: () => root, path: "/", - component: () => ( - - ), + component: () => { + const drawn = ( + + ); + const { asked } = options; + return asked ? ( + asked.push(text)}> + {drawn} + + ) : ( + drawn + ); + }, }); // The destination the card links to for an app connected with a typed key. const settings = createRoute({ @@ -299,3 +316,16 @@ test("does not read a Shared app's deployment account as this person's", async ( ); expect(card.view.queryByText(/Notion is connected/)).toBeNull(); }); + +test("once connected in front of the person, the conversation carries on by itself, once", async () => { + const asked: string[] = []; + const card = mounted({ answers: "connected", asked }); + await waitFor(() => card.view.getByRole("button", { name: /^Connect$/ })); + + press(card); + + await waitFor(() => expect(asked).toEqual([carryOnMessage("Notion")])); + expect(card.view.getByText("Notion is connected.")).toBeTruthy(); + await settleReactWork(); + expect(asked).toHaveLength(1); +}); From 52763bb08214cc0abeeae9ca72fd9f058d429c32 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:18:16 -0300 Subject: [PATCH 07/73] Record who may give an app to a Bot, and whether its owner or an admin gave it --- server/drizzle/0063_owner_app_grants.sql | 4 + server/drizzle/meta/0063_snapshot.json | 10204 +++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/plugins.ts | 37 + 4 files changed, 10252 insertions(+) create mode 100644 server/drizzle/0063_owner_app_grants.sql create mode 100644 server/drizzle/meta/0063_snapshot.json diff --git a/server/drizzle/0063_owner_app_grants.sql b/server/drizzle/0063_owner_app_grants.sql new file mode 100644 index 0000000000..3c3bb5bd28 --- /dev/null +++ b/server/drizzle/0063_owner_app_grants.sql @@ -0,0 +1,4 @@ +ALTER TABLE "mcp_servers" ADD COLUMN "owner_grants" text;--> statement-breakpoint +ALTER TABLE "plugin_grants" ADD COLUMN "granted_as" text;--> statement-breakpoint +ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_owner_grants_check" CHECK ("mcp_servers"."owner_grants" IS NULL OR "mcp_servers"."owner_grants" IN ('any', 'admin'));--> statement-breakpoint +ALTER TABLE "plugin_grants" ADD CONSTRAINT "plugin_grants_granted_as_check" CHECK ("plugin_grants"."granted_as" IS NULL OR "plugin_grants"."granted_as" IN ('admin', 'owner')); \ No newline at end of file diff --git a/server/drizzle/meta/0063_snapshot.json b/server/drizzle/meta/0063_snapshot.json new file mode 100644 index 0000000000..8a8c84c6b9 --- /dev/null +++ b/server/drizzle/meta/0063_snapshot.json @@ -0,0 +1,10204 @@ +{ + "id": "59e401eb-8e91-4157-9ffd-c9e3aeb2dfab", + "prevId": "20465172-4c5a-4639-919a-d1f8d832e6ed", + "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.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_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": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "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": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_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 + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_access_requests": { + "name": "app_access_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_key": { + "name": "app_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_title": { + "name": "app_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "app_access_requests_pending_idx": { + "name": "app_access_requests_pending_idx", + "columns": [ + { + "expression": "requested_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"agent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"app_access_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "app_access_requests_status_idx": { + "name": "app_access_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_access_requests_requested_by_users_id_fk": { + "name": "app_access_requests_requested_by_users_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_access_requests_agent_id_agents_id_fk": { + "name": "app_access_requests_agent_id_agents_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_access_requests_kind_check": { + "name": "app_access_requests_kind_check", + "value": "\"app_access_requests\".\"kind\" IN ('enable', 'grant', 'connect_shared')" + }, + "app_access_requests_status_check": { + "name": "app_access_requests_status_check", + "value": "\"app_access_requests\".\"status\" IN ('pending', 'approved', 'dismissed', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "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 + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_grants": { + "name": "owner_grants", + "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 + }, + "skipped_tools": { + "name": "skipped_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + }, + "mcp_servers_owner_grants_check": { + "name": "mcp_servers_owner_grants_check", + "value": "\"mcp_servers\".\"owner_grants\" IS NULL OR \"mcp_servers\".\"owner_grants\" IN ('any', 'admin')" + } + }, + "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 + }, + "vendor_name": { + "name": "vendor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_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 + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pending_authorizations_server_idx": { + "name": "pending_authorizations_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_authorizations_actor_id_users_id_fk": { + "name": "pending_authorizations_actor_id_users_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + }, + "pending_authorizations_completion_check": { + "name": "pending_authorizations_completion_check", + "value": "(\"pending_authorizations\".\"completed_at\" IS NULL) = (\"pending_authorizations\".\"outcome\" IS NULL)" + }, + "pending_authorizations_user_is_actor_check": { + "name": "pending_authorizations_user_is_actor_check", + "value": "\"pending_authorizations\".\"user_id\" IS NULL OR \"pending_authorizations\".\"user_id\" = \"pending_authorizations\".\"actor_id\"" + } + }, + "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 + }, + "granted_as": { + "name": "granted_as", + "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": { + "plugin_grants_granted_as_check": { + "name": "plugin_grants_granted_as_check", + "value": "\"plugin_grants\".\"granted_as\" IS NULL OR \"plugin_grants\".\"granted_as\" IN ('admin', 'owner')" + } + }, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_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": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "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()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "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": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "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": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_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": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_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()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "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": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "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": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "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.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "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()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index a3b2138466..74a928ec93 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -442,6 +442,13 @@ "when": 1793016088183, "tag": "0062_action_policy_labels", "breakpoints": true + }, + { + "idx": 63, + "version": "7", + "when": 1793027892885, + "tag": "0063_owner_app_grants", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 3c64f186f4..4eaa9e44a4 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -133,6 +133,21 @@ export const mcpServers = pgTable( * any connection row exists, so this is where `authorize` and `confirm` both read it from. */ sharedVendorUserId: text("shared_vendor_user_id"), + /** + * Who may give this app to a Bot: any Bot's owner, for their own Bot (`any`), or only an + * administrator (`admin`). + * + * NULL MEANS THE DEFAULT FOR WHOSE ACCOUNT THE APP RUNS ON, and that is a statement rather than + * a gap. `ownerGrantsOf` in `plugins/app-access.ts` reads it: an app every person connects for + * themselves, and the built-in Routines, default to `any`; an app on the deployment's token, a + * Shared team account or no account at all defaults to `admin`. Stored only once an + * administrator chooses, so an app switched from Personal to Shared falls back to `admin` by + * itself instead of keeping a default somebody chose for the other mode. + * + * Nullable for the reason `docs/development.md` gives under Migrations: a replica from before + * this column writes rows without it. + */ + ownerGrants: text("owner_grants").$type<"any" | "admin">(), /** What the deployment last heard back from it. `null` until the first successful listing. */ toolsRefreshedAt: timestamp("tools_refreshed_at", { withTimezone: true }), /** The last failure, kept so the Plugins page can say why a server has no tools. */ @@ -154,6 +169,10 @@ export const mcpServers = pgTable( "mcp_servers_account_mode_check", sql`${table.accountMode} IS NULL OR ${table.accountMode} IN ('personal', 'shared')`, ), + check( + "mcp_servers_owner_grants_check", + sql`${table.ownerGrants} IS NULL OR ${table.ownerGrants} IN ('any', 'admin')`, + ), ], ); @@ -802,12 +821,30 @@ export const pluginGrants = pgTable( .notNull() .references(() => agents.id, { onDelete: "cascade" }), grantedBy: text("granted_by"), + /** + * Whether an administrator made this grant (`admin`, or null on every row from before this + * column) or the Bot's owner gave one of their own apps to their own Bot (`owner`). + * + * NOT BOOKKEEPING: IT DECIDES WHOSE ACCOUNT A CALL USES. On a published Team Bot, an + * administrator's grant of a personal app goes out on the owner's account wherever the owner + * connected it — `credentialActorFor` in `team-bots/team-bots.ts`. An owner's grant never does: + * it always uses the asking person's own account. Otherwise publishing a Bot would hand every + * teammate the owner's mailbox, which nobody decided except by giving the Bot their own Gmail. + * + * Nullable, null read as `admin`, for the rolling deploy `docs/development.md` describes: a + * replica from before this column only ever wrote an administrator's grant. + */ + grantedAs: text("granted_as").$type<"admin" | "owner">(), grantedAt: createdAt(), updatedAt: updatedAt(), }, (table) => [ primaryKey({ columns: [table.kind, table.ref, table.agentId] }), index("plugin_grants_agent_idx").on(table.agentId), + check( + "plugin_grants_granted_as_check", + sql`${table.grantedAs} IS NULL OR ${table.grantedAs} IN ('admin', 'owner')`, + ), ], ); From 3d5a046ad59cb6ec420c3079b8e3b3b076998baa Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:20:40 -0300 Subject: [PATCH 08/73] Tell a Bot its computer is still starting, rather than failing, when a cold start outlasts the wait --- server/src/computer/client.ts | 31 ++++++++++ server/src/computer/gateway.ts | 24 ++++++-- server/src/computer/headless-tools.ts | 17 ++++++ server/src/computer/sandbox.ts | 16 ++++- server/src/computer/supervisor.ts | 23 +++++++- server/tests/computer-sandbox.test.ts | 50 ++++++++++++++++ server/tests/computer-supervisor.test.ts | 23 ++++++++ server/tests/headless-tools.test.ts | 74 +++++++++++++++++++++++- 8 files changed, 249 insertions(+), 9 deletions(-) diff --git a/server/src/computer/client.ts b/server/src/computer/client.ts index fbc8d59fe1..42fff9ea6b 100644 --- a/server/src/computer/client.ts +++ b/server/src/computer/client.ts @@ -26,6 +26,37 @@ export class ComputerStoppedError extends ComputerUnavailableError { } } +/** + * What every caller is told when a computer did not finish starting in time. + * + * Written for the model and for a person at once, because both read it: the headless tools hand it + * to the model as the result, and the routes send it to the browser, which shows it on the line. + */ +export const STILL_STARTING_MESSAGE = + "The computer is still starting. This call was not performed. Try it again in a moment."; + +/** + * The wait for a cold computer ran out before it could take the call. + * + * A NORMAL WAIT THAT RAN LONG, NOT AN OUTAGE. A computer that was asleep costs a pod schedule and a + * browser launch to wake, and the first start pulls an image as well, so a minute is ordinary and + * two is not unheard of. Reporting that as a failure put red text and "did not become ready" in + * front of somebody whose computer was, at that moment, finishing its start. + * + * A subclass of `ComputerUnavailableError`, so everything that already catches an unavailable + * computer still does. What the type adds is that every surface can say the gentle thing instead: + * the route marks the body `starting`, the headless tools answer with a result rather than a throw, + * and the browser renders it as a wait rather than a failure. The message says the call was NOT + * performed, which is true by construction: this is only thrown before anything reached the + * computer, so a retry cannot do the thing twice. + */ +export class ComputerStillStartingError extends ComputerUnavailableError { + constructor(reason: string = STILL_STARTING_MESSAGE) { + super(reason); + this.name = "ComputerStillStartingError"; + } +} + /** The requested element is not on the current page. */ export class ElementNotFoundError extends Error { constructor(reason: string) { diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index 141ac3503e..4f6c5bc15f 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -27,6 +27,7 @@ import { import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import { withUntrustedNotice } from "../untrusted-content"; import { + ComputerStillStartingError, ComputerStoppedError, ComputerUnavailableError, createComputerTransport, @@ -35,6 +36,7 @@ import { import { checkComputerAddress } from "./target"; export { + ComputerStillStartingError, ComputerUnavailableError, ElementNotFoundError, HandoffRequestError, @@ -368,12 +370,22 @@ export function createComputerGateway( * has to be decided and recorded, and the attempt failing is what writes the failure row beside the * decision; throwing here would take the action off the trail entirely. So a failure answers * "unknown", which leaves the generation check where it was and leaves the address to the attempt. + * + * EXCEPT A COMPUTER THAT IS STILL STARTING, which is carried back rather than forgotten. Forgetting + * it sent the attempt to `locate` again, and that is a second full cold-start wait stacked on the + * first: a click on a sleeping computer held its request open twice as long before saying + * anything. The attempt throws the carried error instead, inside the block that writes the failure + * row, so the trail still shows a permitted action that was not performed. */ - async function locateForAction(botId: string): Promise { + async function locateForAction( + botId: string, + ): Promise<{ address?: string; starting?: ComputerStillStartingError }> { try { - return await locate(botId); - } catch { - return undefined; + return { address: await locate(botId) }; + } catch (error) { + return error instanceof ComputerStillStartingError + ? { starting: error } + : {}; } } @@ -557,7 +569,8 @@ export function createComputerGateway( * the policy has even seen it. The address that comes back is the one the attempt then uses, so * on the supervisor this costs no extra call for the actions that do need it. */ - const address = ref ? await locateForAction(botId) : undefined; + const located = ref ? await locateForAction(botId) : undefined; + const address = located?.address; const { session } = ref ? await sessionOf(botId) : { session: undefined }; const element = resolve(stored, ref, snapshotId, session); // For a navigation the relevant page is the one being opened, not the one already loaded. Using @@ -659,6 +672,7 @@ export function createComputerGateway( * was written would be a way to act without appearing on the trail. The failure row beside it is * what stops the trail claiming a permitted action was carried out when nothing was sent. */ + if (located?.starting) throw located.starting; if (ref && stored && !element) { throw new StaleSnapshotError( `${ref} is not on the page this computer is showing, so nothing can be checked against it before acting. Take a fresh snapshot and use the refs it returns.`, diff --git a/server/src/computer/headless-tools.ts b/server/src/computer/headless-tools.ts index 0036785d8c..701b9f59b3 100644 --- a/server/src/computer/headless-tools.ts +++ b/server/src/computer/headless-tools.ts @@ -8,6 +8,7 @@ import { import { type ActionActor, type ComputerGateway, + ComputerStillStartingError, HumanHasControlError, } from "./gateway"; import type { ControlState } from "./schema"; @@ -63,6 +64,22 @@ export function createHeadlessComputerTools(options: { try { return await execute(input, context); } catch (error) { + /* + * A cold start that outlasted the wait is answered, not thrown. + * + * Thrown, it reached the model as a bare error string beside every real failure, and a + * model reading "error" tends to apologise and give up on a computer that was seconds from + * ready. As a result it says the three things that decide the next step: the computer is + * starting, this call did nothing, and trying again shortly is the right move. + */ + if (error instanceof ComputerStillStartingError) { + return { + ok: false, + starting: true, + notPerformed: true, + reason: error.message, + }; + } if (error instanceof HumanHasControlError) { throw new HeadlessToolSuspension(error.message, { kind: "computer_handoff", diff --git a/server/src/computer/sandbox.ts b/server/src/computer/sandbox.ts index ed9b89660c..e635a55d28 100644 --- a/server/src/computer/sandbox.ts +++ b/server/src/computer/sandbox.ts @@ -15,6 +15,7 @@ * in-cluster service account gives a token and a CA, which is what `inClusterConfig` reads. */ import { readFile } from "node:fs/promises"; +import { ComputerStillStartingError } from "./client"; import type { ComputerLocation, ComputerProvider, @@ -328,9 +329,20 @@ export function createSandboxComputerProvider( return sandbox; } if (Date.now() >= deadline) { - throw new SandboxError( - `The computer for ${botId} did not become ready within ${Math.round(resumeTimeoutMs / 1000)}s. A resume costs a pod schedule and a browser launch, so this is a real wait rather than a failure, but it has to end somewhere.`, + /* + * A resume costs a pod schedule and a browser launch, so this is a real wait rather than a + * failure, but it has to end somewhere. When it does, the caller is told the computer is + * still starting and nothing was done, which is the truth: the pod is very likely seconds + * away. The operator's version, with the Bot and the budget, goes to the log. + */ + console.warn( + JSON.stringify({ + type: "computer-sandbox-still-starting", + botId, + waitedMs: resumeTimeoutMs, + }), ); + throw new ComputerStillStartingError(); } await new Promise((resolve) => setTimeout(resolve, 1_000)); } diff --git a/server/src/computer/supervisor.ts b/server/src/computer/supervisor.ts index 5e25f06130..a6f1a46c46 100644 --- a/server/src/computer/supervisor.ts +++ b/server/src/computer/supervisor.ts @@ -13,6 +13,7 @@ * honest about being one shared computer. */ +import { ComputerStillStartingError } from "./client"; import type { ComputerLocation, ComputerProvider } from "./provider"; import type { ComputerStatus } from "./schema"; @@ -75,7 +76,18 @@ export function createDockerSupervisorProvider( */ const sessions = new Map(); - async function call(path: string, method = "POST"): Promise { + async function call( + path: string, + method = "POST", + /** + * Whether running out of time means the computer is still starting. + * + * Only for `/ensure`, which is the one call that waits on a container coming up: there a timeout + * is a slow start, and the caller is told so gently. Anywhere else it is a supervisor that has + * stopped answering, which is a real fault and keeps its own message. + */ + startingOnTimeout = false, + ): Promise { let response: Response; try { response = await doFetch(`${base}${path}`, { @@ -86,6 +98,13 @@ export function createDockerSupervisorProvider( signal: AbortSignal.timeout(timeoutMs), }); } catch (error) { + if ( + startingOnTimeout && + error instanceof Error && + error.name === "TimeoutError" + ) { + throw new ComputerStillStartingError(); + } throw new SupervisorError( `The container supervisor at ${base} could not be reached (${error instanceof Error ? error.message : String(error)}).`, ); @@ -206,6 +225,8 @@ export function createDockerSupervisorProvider( async locate(botId: string): Promise { const state = (await call( `/computers/${encodeURIComponent(botId)}/ensure`, + "POST", + true, )) as SupervisorComputerLocation; // Recorded on every ensure, so a replaced container is visible to whatever asks next. if (state?.startedAt) sessions.set(botId, state.startedAt); diff --git a/server/tests/computer-sandbox.test.ts b/server/tests/computer-sandbox.test.ts index 09a4c143d7..fc0a6a0e3a 100644 --- a/server/tests/computer-sandbox.test.ts +++ b/server/tests/computer-sandbox.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { ComputerStillStartingError } from "../src/computer/client"; import { createSandboxComputerProvider, sandboxNameFor, @@ -107,6 +108,55 @@ describe("telling one run of a Bot's computer from the next", () => { }); }); +/** + * A cold start that runs past the wait is a wait, not a fault. + * + * It used to throw a `SandboxError` whose message explained the pod schedule to whoever was + * watching, and the route answered 500. Now it is the typed "still starting" every surface knows to + * render gently, and the call it interrupted is said to have done nothing. + */ +describe("a computer that takes longer than the wait to start", () => { + const booting = { + metadata: { name: "bot-general-abc" }, + spec: { operatingMode: "Running" }, + status: { + conditions: [ + { type: "Suspended", status: "False" }, + { type: "Ready", status: "False" }, + ], + }, + }; + + test("locate gives up with the still-starting error, not a sandbox failure", async () => { + const provider = createSandboxComputerProvider({ + namespace: "openbot", + template: { podTemplate: { spec: { containers: [] } } }, + idleAfterMs: 60_000, + apiServer: "https://kubernetes.default", + token: "t", + resumeTimeoutMs: 0, + fetchImpl: (async () => + Response.json(booting)) as unknown as typeof fetch, + }); + const warn = console.warn; + console.warn = () => undefined; + try { + const failure = await provider.locate("general").catch((error) => error); + expect(failure).toBeInstanceOf(ComputerStillStartingError); + expect(failure.message).toContain("not performed"); + } finally { + console.warn = warn; + } + }); + + test("status calls one on its way up starting, without touching it", async () => { + const seen: string[] = []; + const status = await providerWith(booting, seen).status("general"); + expect(status.state).toBe("starting"); + expect(seen.every((call) => call.startsWith("GET "))).toBe(true); + }); +}); + describe("naming a Bot's computer in a cluster", () => { test("a bot id that is not a legal name still gets one, and a unique one", () => { // Bot ids are ours and hold anything a person typed; a resource name may not. Two ids that differ diff --git a/server/tests/computer-supervisor.test.ts b/server/tests/computer-supervisor.test.ts index 4fdf48baa1..d8e2870f0a 100644 --- a/server/tests/computer-supervisor.test.ts +++ b/server/tests/computer-supervisor.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { ComputerStillStartingError } from "../src/computer/client"; import type { ComputerProvider } from "../src/computer/provider"; import { createDockerSupervisorProvider, @@ -88,6 +89,28 @@ describe("locating a Bot's computer", () => { expect(client.locate("sales")).rejects.toThrow(/could not be reached/); }); + test("an ensure that runs out of time is a computer still starting, not a broken supervisor", async () => { + // `/ensure` is the call that waits on a container coming up, so its deadline is a slow start. + const client = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => { + throw new DOMException("The operation timed out.", "TimeoutError"); + }) as unknown as typeof fetch, + }); + const failure = await client.locate("sales").catch((error) => error); + expect(failure).toBeInstanceOf(ComputerStillStartingError); + }); + + test("a timeout on a plain read is still the supervisor's fault", async () => { + const client = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => { + throw new DOMException("The operation timed out.", "TimeoutError"); + }) as unknown as typeof fetch, + }); + expect(client.stop("sales")).rejects.toThrow(SupervisorError); + }); + test("the bot id is escaped into the path", async () => { let seen = ""; const client = createDockerSupervisorProvider({ diff --git a/server/tests/headless-tools.test.ts b/server/tests/headless-tools.test.ts index db4a7c218d..ba4acfdd6c 100644 --- a/server/tests/headless-tools.test.ts +++ b/server/tests/headless-tools.test.ts @@ -9,7 +9,10 @@ import { import { Observable } from "rxjs"; import { parseApprovalContinuation } from "../src/approvals/types"; import type { AuditEventInput } from "../src/audit"; -import { createComputerGateway } from "../src/computer/gateway"; +import { + ComputerStillStartingError, + createComputerGateway, +} from "../src/computer/gateway"; import * as headless from "../src/computer/headless-tools"; import { HeadlessToolsMiddleware } from "@openbot/toolkit/headless-tools"; import { createTurnRunner, type RunnerLike } from "@openbot/toolkit/run-turn"; @@ -386,6 +389,75 @@ test("scheduled computer actions carry the owner and routine into both policy an ); }); +/** + * A cold start that outlasts the wait reaches the model as an answer it can act on. + * + * Thrown, it was one more error string; the model apologised and gave up on a computer that was + * seconds from ready. And a click used to wait for the start twice, once to learn the run and again + * to send, so it is also pinned that the wait happens once. + */ +test("a computer still starting answers 'not performed, try again' rather than throwing, after one wait", async () => { + let locates = 0; + const audit: AuditEventInput[] = []; + const gateway = createComputerGateway({ + provider: { + name: "test", + isolation: "per-bot", + locate: async () => { + locates++; + throw new ComputerStillStartingError(); + }, + status: async (botId) => ({ botId, state: "starting" }), + list: async () => [], + stop: async () => ({ wasRunning: false }), + reset: async () => ({ cleared: false }), + }, + auditStore: { + insert: async (event) => { + audit.push(event); + }, + }, + policy: () => ({ mode: "enforce", allow: ["true"], deny: [] }), + fetchImpl: async () => { + throw new Error("nothing should reach the computer"); + }, + }); + const tools = headless.createHeadlessComputerTools({ + gateway, + botId: "bot", + actor: { id: "owner", userId: "owner" }, + }); + const invoke = (name: string, args: unknown) => { + const tool = tools.find((candidate) => candidate.definition.name === name); + if (!tool) throw new Error(`Missing ${name}`); + return tool.execute(args, { + toolCallId: name, + signal: new AbortController().signal, + }); + }; + + const navigated = await invoke("computer_navigate", { + url: "https://example.com", + }); + expect(navigated).toMatchObject({ + ok: false, + starting: true, + notPerformed: true, + }); + expect(String((navigated as { reason?: string }).reason)).toContain( + "not performed", + ); + + locates = 0; + const clicked = await invoke("computer_click", { ref: "e1", snapshotId: 7 }); + expect(clicked).toMatchObject({ ok: false, starting: true }); + expect(locates).toBe(1); + // Still on the trail: allowed, then the failure that says it did not happen. + expect(audit.map((event) => event.eventType)).toContain( + "computer.action_failed", + ); +}); + test("the headless computer adapter executes shipped browser, command, and file tools through the gateway", async () => { expect(typeof headless.createHeadlessComputerTools).toBe("function"); const { gateway, received, audit } = computerHarness(); From bc4fd985be37d676b147894f487490edb42a2e87 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:20:40 -0300 Subject: [PATCH 09/73] Answer a screenshot of a computer that is not ready with its state, without waking it --- server/src/computer/routes.ts | 38 ++++++++++- server/tests/computer-routes.test.ts | 94 +++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index c88016e886..512e3ec8ec 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -10,6 +10,7 @@ import { type ActionActor, ActionRefusedError, type ComputerGateway, + ComputerStillStartingError, ComputerUnavailableError, ElementNotFoundError, HandoffRequestError, @@ -98,9 +99,36 @@ export function createComputerRoutes( return context.json(await gateway.status(botId)); }); + /** + * The frame a watcher polls for, and never the thing that wakes a computer. + * + * `screenshot` goes through `locate`, and `locate` resumes a suspended computer and waits for it. + * Called blind, opening the panel was enough to start a Bot's computer, every poll after that held + * a request open for the length of a cold start, and the first one to give up was drawn as "can't + * show the screen". The computer starts on the first thing a Bot does with it; looking at it is + * not that. + * + * So the status is asked first, which reads the provider's own record and touches nothing. A + * computer that is not ready answers 200 with `ready: false` and its state: an answer, not an + * error, because a computer that is asleep or starting is behaving normally and the panel draws + * it as such. Only an unreachable one is a fault, and the state says that too. + * + * There is a gap between the two calls, and a computer suspended inside it is woken by the + * screenshot. The culler suspends after a long idle, so landing in that gap needs a viewer polling + * at the exact moment of the suspend; the cost is one computer started early, never a wrong frame. + */ routes.get("/:botId/screenshot", async (context) => { + const botId = context.req.param("botId"); try { - return context.json(await gateway.screenshot(context.req.param("botId"))); + const status = await gateway.status(botId); + if (status.state !== "ready") { + return context.json({ + ready: false, + state: status.state, + ...(status.reason ? { reason: status.reason } : {}), + }); + } + return context.json(await gateway.screenshot(botId)); } catch (error) { return context.json(errorBody(error), statusFor(error)); } @@ -994,6 +1022,14 @@ function errorBody(error: unknown): Record { ...(error instanceof HandoffRequestError ? { controlRequestError: true } : {}), + /* + * A cold start that outlasted the wait. Marked so the browser can draw a wait rather than a + * failure, and tell the model the call was not performed and is worth repeating. Without the + * flag it reads as any other unavailable computer, which sends a person looking for an outage. + */ + ...(error instanceof ComputerStillStartingError + ? { starting: true, notPerformed: true } + : {}), }; } diff --git a/server/tests/computer-routes.test.ts b/server/tests/computer-routes.test.ts index 3c4eae8873..c7a6f50667 100644 --- a/server/tests/computer-routes.test.ts +++ b/server/tests/computer-routes.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; import type { AppVariables, AuthenticatedActor } from "../src/auth/guards"; -import type { ComputerGateway } from "../src/computer/gateway"; +import { + type ComputerGateway, + ComputerStillStartingError, +} from "../src/computer/gateway"; import type { PolicyStore } from "../src/computer/policy-store"; import { createComputerRoutes } from "../src/computer/routes"; @@ -9,6 +12,7 @@ describe("computer routes", () => { test("gets a screenshot through the governed computer gateway", async () => { const requestedBotIds: string[] = []; const gateway = { + status: async (botId: string) => ({ botId, state: "ready" }), screenshot: async (botId: string) => { requestedBotIds.push(botId); return { image: "aGVsbG8=", mimeType: "image/png" as const }; @@ -41,6 +45,94 @@ describe("computer routes", () => { }); }); +/** + * Watching a computer must not start it, and a slow start must not read as a failure. + * + * `screenshot` resumes a suspended computer on the way, so the panel polling it once a second used + * to be what woke every Bot it was opened on, and the first poll to give up drew "can't show the + * screen" over a computer that was simply still booting. + */ +describe("a computer that is not ready yet", () => { + function routesFor(gateway: Partial) { + const requireUser: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { id: "user-1", email: "u@openbot.test" } as never); + await next(); + }; + return createComputerRoutes( + gateway as ComputerGateway, + {} as PolicyStore, + requireUser, + async () => true, + ); + } + + test.each([ + "absent", + "starting", + ] as const)("answers %s as a state, without taking a screenshot", async (state) => { + let screenshots = 0; + const routes = routesFor({ + status: async (botId: string) => ({ botId, state }), + screenshot: async () => { + screenshots++; + return { base64: "AAAA", width: 1, height: 1, capturedAt: "" }; + }, + }); + + const response = await routes.request("http://openbot.test/b/screenshot"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ready: false, state }); + expect(screenshots).toBe(0); + }); + + test("passes an unreachable computer's reason through, still as an answer", async () => { + const routes = routesFor({ + status: async (botId: string) => ({ + botId, + state: "unreachable", + reason: "The supervisor could not be reached.", + }), + screenshot: async () => { + throw new Error("must not be called"); + }, + }); + + const response = await routes.request("http://openbot.test/b/screenshot"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ready: false, + state: "unreachable", + reason: "The supervisor could not be reached.", + }); + }); + + test("an action whose cold start ran out says it was not performed, and is marked as starting", async () => { + const routes = routesFor({ + navigate: async () => { + throw new ComputerStillStartingError(); + }, + }); + + const response = await routes.request("http://openbot.test/b/navigate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: "https://example.com" }), + }); + + expect(response.status).toBe(503); + const body = (await response.json()) as Record; + expect(body.starting).toBe(true); + expect(body.notPerformed).toBe(true); + expect(String(body.error)).toContain("still starting"); + expect(String(body.error)).toContain("not performed"); + }); +}); + /** * The fleet listing is the one route here that is not about the Bot in its path. * From 745501fa7722ed96a9906a32f0a5f66a48d12bc0 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:21:33 -0300 Subject: [PATCH 10/73] Tell whether a Bot holds a search tool, from its refs or its tool names holdsWebSearch reads the grant refs a built-in Bot is built with, and researchRoleOfToolName reads the mcp__server__tool names a Bot running its own loop is offered. Either counts a search tool from any research provider, without needing its reader alongside. RESEARCH_APP_NAMES lists the catalogue's search apps by title, the name show_app_access resolves. There is no app called "search", so a Bot with none has to name one, and listing every provider keeps the choice with the administrator. A test holds the list to the catalogue's titles. --- server/src/plugins/tools.ts | 13 +++++++++- server/tests/web-research.test.ts | 42 ++++++++++++++++++++++++++++++- shared/research.ts | 32 +++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index 9496f2f6a9..843db6146f 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -80,7 +80,7 @@ export function vendorAnswer(result: { text: string; isError: boolean }) { * A tool array tells a model a tool exists. It does not tell it that the tool is the right way to * reach that system, and it competes with a page of prose about the browser that every Bot is given * whether or not it has any connectors at all. The browser prose wins: it is emphatic, it is about - * capability, and it says "never claim you cannot browse". + * capability, and it used to say "never claim you cannot browse". * * So a Bot holding four Google Drive tools browsed to drive.google.com, met a sign-in page its * container could never satisfy, and asked its person to sign in to a vendor that person had already @@ -204,6 +204,17 @@ type ResearchProvider = { fetch: GrantedTool; }; +/** + * Whether these tools include one that finds sources, from any research provider. + * + * The search role alone, not the full pair `researchProviders` wants: a Bot holding a search tool and + * no reader can still search, and telling it to drive its browser to a search engine instead would + * be the very routing mistake this decides against. See `computerGuidance`. + */ +export function holdsWebSearch(tools: readonly GrantedTool[]): boolean { + return tools.some((tool) => researchRoleFor(tool.ref) === "search"); +} + /** * The research providers this Bot can actually use: a search tool and a fetch tool from one server. * diff --git a/server/tests/web-research.test.ts b/server/tests/web-research.test.ts index 4e4d30d92f..e56320d738 100644 --- a/server/tests/web-research.test.ts +++ b/server/tests/web-research.test.ts @@ -2,12 +2,15 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import type { GrantedTool } from "@openbot/toolkit/granted-tool"; import { + RESEARCH_APP_NAMES, RESEARCH_TOOLS as SHARED_RESEARCH_TOOLS, researchRoleOf, + researchRoleOfToolName, } from "@openbot/shared/research"; import { parse } from "yaml"; import { z } from "zod"; import { + CATALOGUE, catalogueEntry, classifyTool, researchRoleFor, @@ -17,7 +20,7 @@ import { } from "../src/plugins/catalogue"; import { listTools } from "../src/plugins/mcp"; import { selectTools } from "../src/plugins/selection"; -import { grantedToolGuidance } from "../src/plugins/tools"; +import { grantedToolGuidance, holdsWebSearch } from "../src/plugins/tools"; const tool = (server: string, name: string): GrantedTool => ({ name: `mcp__${server}__${name}`, @@ -37,6 +40,43 @@ const RESEARCH_TOOLS: Record = { const pair = (server = "parallel") => (RESEARCH_TOOLS[server] ?? []).map((name) => tool(server, name)); +describe("whether a Bot holds a search tool", () => { + test("any provider's search tool counts, from its ref or the name a model sees", () => { + for (const [server, [search, fetch]] of Object.entries(RESEARCH_TOOLS)) { + expect(holdsWebSearch([tool(server, search)])).toBe(true); + expect(holdsWebSearch([tool(server, fetch)])).toBe(false); + expect(researchRoleOfToolName(`mcp__${server}__${search}`)).toBe( + "search", + ); + expect(researchRoleOfToolName(`mcp__${server}__${fetch}`)).toBe("fetch"); + } + expect(holdsWebSearch([])).toBe(false); + expect(holdsWebSearch([tool("google-drive", "search_files")])).toBe(false); + expect(researchRoleOfToolName("web_search")).toBeNull(); + expect( + researchRoleOfToolName("mcp__google-drive__search_files"), + ).toBeNull(); + }); + + test("the search apps a Bot without one offers are the catalogue's, by a name the card resolves", () => { + // The card resolves an app by key or title. A renamed title would leave a Bot offering a card + // for an app this deployment has no name for. + const research = CATALOGUE.filter((entry) => + Object.hasOwn(SHARED_RESEARCH_TOOLS, entry.key), + ); + const named = research.filter((entry) => + RESEARCH_APP_NAMES.includes(entry.title), + ); + expect(named.map((entry) => entry.title).sort()).toEqual( + [...RESEARCH_APP_NAMES].sort(), + ); + // Every research vendor is offered, so the choice stays the administrator's. + expect(new Set(named.map((entry) => entry.vendor))).toEqual( + new Set(research.map((entry) => entry.vendor)), + ); + }); +}); + describe("research providers in the catalogue", () => { test("each is pinned to its vendor's official endpoint", () => { const urls: Record = { diff --git a/shared/research.ts b/shared/research.ts index 2763697691..6b3d31d081 100644 --- a/shared/research.ts +++ b/shared/research.ts @@ -91,3 +91,35 @@ export function researchRoleOf(ref: string): ResearchRole | null { if (prefix !== RESEARCH_ROLE_PREFIX || extra !== undefined) return null; return role === "search" || role === "fetch" ? role : null; } + +/** + * The research role a tool plays, from the name a model is offered it under, or null. + * + * Granted tools reach a model as `mcp____`. A Bot running its own loop (`agent-bot`, + * `agent-langgraph`) sees only those names, not the refs the server holds, so this is how it learns + * whether it was offered a search tool on this run. + */ +export function researchRoleOfToolName(name: string): ResearchRole | null { + if (!name.startsWith("mcp__")) return null; + const rest = name.slice("mcp__".length); + const separator = rest.indexOf("__"); + if (separator <= 0) return null; + return researchRoleOfTool( + rest.slice(0, separator), + rest.slice(separator + 2), + ); +} + +/** + * The search apps a Bot holding none may offer the person, by a name the app card resolves. + * + * Titles of the free catalogue entries, because `show_app_access` resolves an app's key or title and + * nothing more general: there is no app called "search". All of them rather than one, because which + * search app a deployment uses is the administrator's choice. A name here that is not a research + * entry's title is a test failure, so the list cannot drift from the catalogue. + */ +export const RESEARCH_APP_NAMES: readonly string[] = Object.freeze([ + "Parallel Search", + "Exa Search", + "Firecrawl", +]); From c868c84fbaff3387fce2c2819b2cd00eb4b33693 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:21:40 -0300 Subject: [PATCH 11/73] Send a search to the search tool, and never to the browser Asked to "search for" something in a demo, a Bot drove its browser to a search engine. The computer guidance told it to navigate whenever asked to look at, check or read anything, and never to claim it cannot browse; the one line saying a page is not a search read as a hedge beside them. computerGuidance now takes what the run holds and says one thing: - With a search tool, finding information goes to it, and the computer is for signing in, clicking through, filling in forms, or opening a page the person asked to see. - Without one, the browser never goes to Google, Bing or DuckDuckGo to look something up. The Bot answers from what it knows, says it has not checked it, and calls show_app_access once with one of Parallel Search, Exa Search or Firecrawl so the person can ask for it. "Never claim you cannot browse" is gone; opening a page the person names is still something the Bot does. Every path gets the right wording. Built-in Bots are worded in builtInAgentConfiguration from the tools they are built with, so a narrowed run follows what it was offered. agent-bot and agent-langgraph work it out from the tool names each run offers them, which are the only view they have of their grants. --- agent-bot/src/history.ts | 17 +++- agent-bot/tests/history.test.ts | 37 +++++++ agent-langgraph/src/history.ts | 12 ++- agent-langgraph/tests/history.test.ts | 25 ++++- server/src/agents/agent-resolver.ts | 3 +- server/src/copilot.ts | 24 +++-- server/src/index.ts | 4 +- server/tests/copilot.test.ts | 91 ++++++++++++++++- shared/bot-prompt.test.ts | 102 +++++++++++++++---- shared/bot-prompt.ts | 140 +++++++++++++++++++------- 10 files changed, 382 insertions(+), 73 deletions(-) diff --git a/agent-bot/src/history.ts b/agent-bot/src/history.ts index 670cf1b987..c663b2173b 100644 --- a/agent-bot/src/history.ts +++ b/agent-bot/src/history.ts @@ -7,7 +7,8 @@ */ import type { RunAgentInput } from "@ag-ui/core"; import type OpenAI from "openai"; -import { COMPUTER_GUIDANCE, NO_ANSWER_CAME } from "../../shared/bot-prompt"; +import { computerGuidance, NO_ANSWER_CAME } from "../../shared/bot-prompt"; +import { researchRoleOfToolName } from "../../shared/research"; import { userContent } from "../../shared/user-content"; export { NO_ANSWER_CAME }; @@ -17,7 +18,19 @@ export function toProviderMessages( input: RunAgentInput, ): OpenAI.Chat.ChatCompletionMessageParam[] { const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: COMPUTER_GUIDANCE }, + /* + * Worded for the tools this run offers, which is all this Bot can see of its grants: the names + * say whether one of them is a search tool, and that decides whether the browser is told to + * leave searching to it or never to search at all. + */ + { + role: "system", + content: computerGuidance({ + search: (input.tools ?? []).some( + (tool) => researchRoleOfToolName(tool.name) === "search", + ), + }), + }, // AG-UI application context is separate from history. The A2UI catalog and tool instructions // arrive here; omitting them leaves the model guessing component names and action schemas. ...(input.context ?? []).map(({ description, value }) => ({ diff --git a/agent-bot/tests/history.test.ts b/agent-bot/tests/history.test.ts index 6fcb03a095..389e458405 100644 --- a/agent-bot/tests/history.test.ts +++ b/agent-bot/tests/history.test.ts @@ -1,7 +1,44 @@ import { describe, expect, test } from "bun:test"; import type { RunAgentInput } from "@ag-ui/core"; +import { computerGuidance } from "../../shared/bot-prompt"; import { NO_ANSWER_CAME, toProviderMessages } from "../src/history"; +/** + * Whether the browser is told to leave searching to a search tool is decided by the tools this run + * offers, since the names are all this Bot sees of its grants. "search for X" made a Bot holding + * a search tool drive its browser to a search engine instead. + */ +describe("the computer guidance this Bot is given", () => { + const offered = (...names: string[]) => + ({ + messages: [{ id: "u1", role: "user", content: "Search for X." }], + tools: names.map((name) => ({ name, description: "", parameters: {} })), + }) as unknown as RunAgentInput; + + test("leaves searching to a search tool the run offers", () => { + const [system] = toProviderMessages( + offered("show_app_access", "mcp__exa__web_search_exa"), + ); + expect(system).toEqual({ + role: "system", + content: computerGuidance({ search: true }), + }); + }); + + test("never searches in the browser when no search tool is offered", () => { + for (const run of [ + offered("show_app_access", "mcp__google-drive__search_files"), + // A reader alone does not find sources. + offered("mcp__exa__web_fetch_exa"), + { messages: [] } as unknown as RunAgentInput, + ]) + expect(toProviderMessages(run)[0]).toEqual({ + role: "system", + content: computerGuidance({ search: false }), + }); + }); +}); + /** * The Bot that ships in the box, and the conversation a declined handover used to end. * diff --git a/agent-langgraph/src/history.ts b/agent-langgraph/src/history.ts index 6fe53b1ab4..861995d28d 100644 --- a/agent-langgraph/src/history.ts +++ b/agent-langgraph/src/history.ts @@ -13,7 +13,8 @@ import { SystemMessage, ToolMessage, } from "@langchain/core/messages"; -import { COMPUTER_GUIDANCE, NO_ANSWER_CAME } from "../../shared/bot-prompt"; +import { computerGuidance, NO_ANSWER_CAME } from "../../shared/bot-prompt"; +import { researchRoleOfToolName } from "../../shared/research"; import { userContent } from "../../shared/user-content"; /* @@ -39,7 +40,14 @@ export function toLangChainMessages( provider = "openai", ): BaseMessage[] { const messages: BaseMessage[] = [ - new SystemMessage(COMPUTER_GUIDANCE), + // Worded for the tools this run offers; see `computerGuidance` and agent-bot's history.ts. + new SystemMessage( + computerGuidance({ + search: (input.tools ?? []).some( + (tool) => researchRoleOfToolName(tool.name) === "search", + ), + }), + ), // AG-UI carries application context separately from conversation history. CopilotKit puts // the A2UI catalog and tool instructions here; dropping it leaves the model guessing the // component schema and can strand the renderer on an invalid, never-painted surface. diff --git a/agent-langgraph/tests/history.test.ts b/agent-langgraph/tests/history.test.ts index 50c468c422..bd18cb9de0 100644 --- a/agent-langgraph/tests/history.test.ts +++ b/agent-langgraph/tests/history.test.ts @@ -7,7 +7,7 @@ import { ToolMessage, } from "@langchain/core/messages"; import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; -import { COMPUTER_GUIDANCE } from "../../shared/bot-prompt"; +import { computerGuidance } from "../../shared/bot-prompt"; import { NO_ANSWER_CAME, toLangChainMessages } from "../src/history"; /** @@ -26,6 +26,27 @@ import { NO_ANSWER_CAME, toLangChainMessages } from "../src/history"; const input = (messages: unknown[]): RunAgentInput => ({ messages }) as unknown as RunAgentInput; +describe("the computer guidance this Bot is given", () => { + // Decided by the tool names the run offers, the only view this Bot has of its grants. + const offered = (...names: string[]) => + ({ + messages: [{ id: "u1", role: "user", content: "Search for X." }], + tools: names.map((name) => ({ name, description: "", parameters: {} })), + }) as unknown as RunAgentInput; + + test("leaves searching to a search tool the run offers", () => { + const [system] = toLangChainMessages( + offered("show_app_access", "mcp__parallel__web_search"), + ); + expect(String(system?.content)).toBe(computerGuidance({ search: true })); + }); + + test("never searches in the browser when no search tool is offered", () => { + const [system] = toLangChainMessages(offered("show_app_access")); + expect(String(system?.content)).toBe(computerGuidance({ search: false })); + }); +}); + const assistantAsking = { role: "assistant", content: "", @@ -273,7 +294,7 @@ describe("a provider that takes one system prompt", () => { expect(system).toHaveLength(1); expect(messages[0]).toBe(system[0] as SystemMessage); const prompt = String(system[0]?.content); - expect(prompt.startsWith(COMPUTER_GUIDANCE)).toBe(true); + expect(prompt.startsWith(computerGuidance({ search: false }))).toBe(true); const at = instructions.map((text) => prompt.indexOf(text)); expect(at.every((index) => index > 0)).toBe(true); expect(at).toEqual([...at].sort((a, b) => a - b)); diff --git a/server/src/agents/agent-resolver.ts b/server/src/agents/agent-resolver.ts index 1314d4444f..f14926d529 100644 --- a/server/src/agents/agent-resolver.ts +++ b/server/src/agents/agent-resolver.ts @@ -1,4 +1,5 @@ import type { AbstractAgent } from "@ag-ui/client"; +import type { ComputerGuidance } from "@openbot/shared/bot-prompt"; import type { AuditInitiator } from "../audit"; import type { AgentFetch, StallGuard } from "../channels/stall-guard"; import { @@ -49,7 +50,7 @@ export type ActorAgentResolverDependencies = { initiator?: AuditInitiator, depth?: number, ) => SignRun; - computerGuidance?: string; + computerGuidance?: ComputerGuidance; loadVendors?: () => Promise; selectionForActor?: (actorId: string) => ToolSelection; agentFetch?: AgentFetch; diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 5c5bca798a..ac0b822079 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -6,7 +6,10 @@ import { CopilotRuntime, } from "@copilotkit/runtime/v2"; import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono"; -import { PROVENANCE_GUIDANCE } from "@openbot/shared/bot-prompt"; +import { + type ComputerGuidance, + PROVENANCE_GUIDANCE, +} from "@openbot/shared/bot-prompt"; import { defaultModelFor } from "@openbot/shared/model-providers"; import { BuiltInAgentWithSaneHistory, @@ -65,7 +68,7 @@ import { SELECTION_FLOOR, selectTools, } from "./plugins/selection"; -import { grantedToolGuidance } from "./plugins/tools"; +import { grantedToolGuidance, holdsWebSearch } from "./plugins/tools"; import { UNTRUSTED_GUIDANCE } from "./untrusted-content"; /** @@ -375,8 +378,11 @@ export function builtInAgentConfiguration( * what its hands are. Absent leaves the role alone, which is right for a deployment with no * computer configured, where the browser routes are not mounted and a Bot promised a browser would * be promising something that does not exist. + * + * Worded for the tools passed here, which are what this run is offered: whether one of them finds + * sources decides whether the browser is told to leave searching to it or never to search at all. */ - computerGuidance?: string, + computerGuidance?: ComputerGuidance, /** * Vendors this deployment connects to, whether or not this Bot holds any of their tools. * @@ -445,7 +451,9 @@ export function builtInAgentConfiguration( ...(grantedToolGuidance(tools, connectedVendors) ? [grantedToolGuidance(tools, connectedVendors)] : []), - ...(computerGuidance ? [computerGuidance] : []), + ...(computerGuidance + ? [computerGuidance({ search: holdsWebSearch(tools) })] + : []), ...(learnedSkills?.catalog ? [learnedSkills.catalog] : []), ].join("\n\n"), ...(planModel ? {} : { apiKey: apiKey ?? undefined }), @@ -501,7 +509,7 @@ export async function buildAgents( loadTools: LoadToolsForBot = async () => [], signRun?: SignRun, /** What every built-in Bot is told about the computer. Absent means this deployment has none. */ - computerGuidance?: string, + computerGuidance?: ComputerGuidance, /** * Which vendors this deployment connects to. Asked once per build rather than per Bot, because it * is a fact about the deployment; what differs per Bot is which of them it holds. @@ -847,7 +855,7 @@ async function buildAgent( stallGuard: StallGuard | undefined, loadTools: LoadToolsForBot, signRun?: SignRun, - computerGuidance?: string, + computerGuidance?: ComputerGuidance, connectedVendors: readonly string[] = [], selection?: ToolSelection, agentFetch?: AgentFetch, @@ -1285,7 +1293,7 @@ function remoteAgentWithStandingRole( * the same reason the tools are, so switching a connector on reaches the next run. * * The remote path needs this more than the built-in one, not less. A framework Bot is handed the - * tools as an offer and decides for itself what to call, with `COMPUTER_GUIDANCE` as its whole + * tools as an offer and decides for itself what to call, with `computerGuidance` as its whole * prompt — a page about the browser that mentions connectors nowhere. That is the Bot that browsed * to drive.google.com holding four Drive tools. * @@ -1566,7 +1574,7 @@ export async function resolveRuntimeAgents( stallGuard?: StallGuard, loadTools?: LoadToolsForBot, signRun?: SignRun, - computerGuidance?: string, + computerGuidance?: ComputerGuidance, loadVendors?: () => Promise, selection?: ToolSelection, agentFetch?: AgentFetch, diff --git a/server/src/index.ts b/server/src/index.ts index 77faeca575..fa979735d8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,7 +3,7 @@ import { CopilotKitIntelligence, IntelligenceAgentRunner, } from "@copilotkit/runtime/v2"; -import { COMPUTER_GUIDANCE } from "@openbot/shared/bot-prompt"; +import { computerGuidance } from "@openbot/shared/bot-prompt"; import { workOwner } from "@openbot/shared/work-owner"; import { createTurnRunner } from "@openbot/toolkit/run-turn"; import { and, eq, isNull, ne, sql } from "drizzle-orm"; @@ -1704,7 +1704,7 @@ const actorAgentResolver = createActorAgentResolver({ proactiveReadOnlyRefs, ), signRunForActor, - computerGuidance: config.computer ? COMPUTER_GUIDANCE : undefined, + computerGuidance: config.computer ? computerGuidance : undefined, loadVendors, selectionForActor, agentFetch, diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts index 978a0811d9..6e3c266c98 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -5,7 +5,10 @@ import type { AbstractAgent, RunAgentInput } from "@ag-ui/client"; import { HttpAgent } from "@ag-ui/client"; import { LLMock } from "@copilotkit/aimock"; import { BuiltInAgent } from "@copilotkit/runtime/v2"; -import { PROVENANCE_GUIDANCE } from "@openbot/shared/bot-prompt"; +import { + computerGuidance, + PROVENANCE_GUIDANCE, +} from "@openbot/shared/bot-prompt"; import { defaultModelFor } from "@openbot/shared/model-providers"; import { EMPTY, lastValueFrom } from "rxjs"; import { z } from "zod"; @@ -1133,7 +1136,7 @@ describe("what a Bot is told it holds", () => { { provider: "openai", defaultModel: "gpt-5.6-terra" }, "openai-secret", drive, - "BROWSER GUIDANCE HERE", + () => "BROWSER GUIDANCE HERE", ).prompt as string; // Order is the fix, not merely presence: the grants have to land before the browser prose. @@ -1144,6 +1147,88 @@ describe("what a Bot is told it holds", () => { }); }); +/** + * Whether the browser leaves searching to a search tool, decided by what this run holds. + * + * "search for X" in a demo made a Bot drive its browser to a search engine. The guidance has two + * wordings, and which one a built-in Bot gets has to follow the tools it is built with, including a + * narrowed run that was not offered the search tool it was granted. + */ +describe("what a built-in Bot is told about searching", () => { + const promptHolding = ( + tools: { name: string; ref: string }[], + guidance: typeof computerGuidance | null = computerGuidance, + ) => { + const configuration = builtInAgentConfiguration( + { + id: "general-assistant", + name: "General Assistant", + type: "built_in", + systemPrompt: "Be helpful.", + }, + { provider: "openai", defaultModel: "gpt-5.6-terra" }, + "openai-secret", + tools as never[], + guidance ?? undefined, + ); + return String((configuration as { prompt?: unknown }).prompt); + }; + const card = { name: "show_app_access", ref: "app-access/show_app_access" }; + const exaSearch = { + name: "mcp__exa__web_search_exa", + ref: "exa/web_search_exa", + }; + const exaFetch = { + name: "mcp__exa__web_fetch_exa", + ref: "exa/web_fetch_exa", + }; + + test.each([ + ["a provider's pair", [card, exaSearch, exaFetch]], + ["a search tool alone", [card, exaSearch]], + [ + "another provider", + [ + card, + { + name: "mcp__firecrawl__firecrawl_search", + ref: "firecrawl/firecrawl_search", + }, + ], + ], + ])("holding %s, it is told to search with it", (_, tools) => { + const prompt = promptHolding(tools); + expect(prompt).toContain(computerGuidance({ search: true })); + expect(prompt).not.toContain(computerGuidance({ search: false })); + }); + + test.each([ + ["nothing", []], + ["only the card", [card]], + ["a reader with no search", [card, exaFetch]], + [ + "a tool called search that finds no sources", + [ + card, + { + name: "mcp__google-drive__search_files", + ref: "google-drive/search_files", + }, + ], + ], + ])("holding %s, it is told never to search in the browser", (_, tools) => { + const prompt = promptHolding(tools); + expect(prompt).toContain(computerGuidance({ search: false })); + expect(prompt).not.toContain(computerGuidance({ search: true })); + }); + + test("a deployment with no computer is told about neither", () => { + const prompt = promptHolding([card, exaSearch], null); + expect(prompt).toContain("Be helpful."); + expect(prompt).not.toContain("computer_navigate"); + }); +}); + /** * Where an answer came from, on every Bot rather than the ones somebody remembered. * @@ -1277,7 +1362,7 @@ describe("a person's standing instructions", () => { model, "openai-secret", [{ name: "mcp__google-drive__search_files" }] as never[], - "Computer guidance.", + () => "Computer guidance.", [], "Write in British English.", ).prompt as string; diff --git a/shared/bot-prompt.test.ts b/shared/bot-prompt.test.ts index 2f5d14f34d..2a784b3a8a 100644 --- a/shared/bot-prompt.test.ts +++ b/shared/bot-prompt.test.ts @@ -1,31 +1,99 @@ import { describe, expect, test } from "bun:test"; -import { COMPUTER_GUIDANCE } from "./bot-prompt"; +import { computerGuidance } from "./bot-prompt"; +import { RESEARCH_APP_NAMES } from "./research"; -describe("COMPUTER_GUIDANCE", () => { - test("keeps paragraph breaks as blank lines instead of collapsing them into spaces", () => { - expect(COMPUTER_GUIDANCE).toContain("\n\n"); - expect(COMPUTER_GUIDANCE).not.toContain(" "); - }); +const WITH_SEARCH = computerGuidance({ search: true }); +const WITHOUT_SEARCH = computerGuidance({ search: false }); + +/** One sentence can span a source line break, which is not a fact about what the Bot is told. */ +const flat = (text: string) => text.toLowerCase().replace(/\s+/g, " "); - test("keeps each paragraph as one unbroken line of prose", () => { - for (const paragraph of COMPUTER_GUIDANCE.split("\n\n")) { +describe("computerGuidance", () => { + test.each([ + ["with a search tool", WITH_SEARCH], + ["without one", WITHOUT_SEARCH], + ])("keeps paragraph breaks as blank lines %s", (_, guidance) => { + expect(guidance).toContain("\n\n"); + expect(guidance).not.toContain(" "); + for (const paragraph of guidance.split("\n\n")) { expect(paragraph).not.toContain("\n"); expect(paragraph.length).toBeGreaterThan(0); } }); - test("still contains the full instruction text, unchanged in wording", () => { - expect(COMPUTER_GUIDANCE).toContain( - "You are a Bot with your own computer, a real web browser the person can watch you use.", + test.each([ + ["with a search tool", WITH_SEARCH], + ["without one", WITHOUT_SEARCH], + ])( + "still says the computer can open a page the person names, %s", + (_, guidance) => { + expect(guidance).toContain( + "You are a Bot with your own computer, a real web browser the person can watch you use.", + ); + expect(flat(guidance)).toContain( + "when you are asked to open, visit or read a particular web page, call computer_navigate", + ); + expect(guidance).toContain( + "Say what you found or did in plain language, briefly.", + ); + }, + ); + + test.each([ + ["with a search tool", WITH_SEARCH], + ["without one", WITHOUT_SEARCH], + ])( + "no longer insists the Bot never says it cannot browse, %s", + (_, guidance) => { + // The emphatic line that out-argued the hedge beside it in the demo. + expect(flat(guidance)).not.toContain("never claim you cannot browse"); + // "check" sent "check the latest on X" to the browser. + expect(flat(guidance)).not.toContain("look at, open, visit, check"); + }, + ); + + test("with a search tool, finding information goes to it and never to the browser", () => { + const guidance = flat(WITH_SEARCH); + expect(guidance).toContain( + "finding information is not a job for your computer", ); - expect(COMPUTER_GUIDANCE).toContain( - "Say what you found or did in plain language, briefly.", + // The three phrasings the routing has to survive. + expect(guidance).toContain("search for something"); + expect(guidance).toContain("find the latest on a subject"); + expect(guidance).toContain("find a few examples of something"); + expect(guidance).toContain("use your search tool"); + expect(guidance).toContain("do not open a search engine in your browser"); + // What the computer is still for. + expect(guidance).toContain( + "signing in, clicking through a site, filling in a form, or opening a page the person asked to see", ); + // Never told to navigate for a search, and never told it cannot search. + expect(guidance).not.toMatch(/search[^.]*call computer_navigate/); + expect(guidance).not.toContain("you do not hold a search tool"); + expect(guidance).not.toContain("cannot search"); }); - test("does not let the browser pass for a web search", () => { - // Asked "can you search the web?", a Bot with only a browser said yes. - expect(COMPUTER_GUIDANCE).toContain("Opening a page is not a web search."); - expect(COMPUTER_GUIDANCE).toContain("cannot search"); + test("without a search tool, the browser is never a search engine", () => { + const guidance = flat(WITHOUT_SEARCH); + expect(guidance).toContain("opening a page is not a web search"); + expect(guidance).toContain("never drive your browser to a search engine"); + for (const engine of ["google", "bing", "duckduckgo"]) + expect(guidance).toContain(engine); + expect(guidance).not.toContain("use your search tool"); + }); + + test("without a search tool, it answers marked as unchecked and offers a search app", () => { + const guidance = flat(WITHOUT_SEARCH); + expect(guidance).toContain("answer from what you already know"); + expect(guidance).toContain("you have not checked it against a source"); + expect(guidance).toContain("call it once in the conversation"); + expect(guidance).toContain("show_app_access"); + for (const name of RESEARCH_APP_NAMES) + expect(guidance).toContain(name.toLowerCase()); + expect(guidance).toContain( + "otherwise say an administrator can add a search app", + ); + // Asked "can you search the web?", a Bot with only a browser once said yes. + expect(guidance).toContain("cannot search"); }); }); diff --git a/shared/bot-prompt.ts b/shared/bot-prompt.ts index 306e97566b..c06618529a 100644 --- a/shared/bot-prompt.ts +++ b/shared/bot-prompt.ts @@ -9,21 +9,67 @@ * only their role: asked to file an issue on a site it was not signed in to, one browsed to the page * and then said it could not, never calling `computer_request_help` to have a person sign in. */ +import { SHOW_APP_ACCESS_TOOL } from "./connect-prompt"; +import { RESEARCH_APP_NAMES } from "./research"; + +const OPENING_A_PAGE_LINES = [ + "You are a Bot with your own computer, a real web browser the person can watch you use.", + "When you are asked to open, visit or read a particular web page, call computer_navigate: that is", + "something you can actually do.", + "Opening a page returns its title and its readable text. Answer from that text.", + "Never tell the person to go and look at the page themselves: you have already read it.", +]; + +/** + * Where finding something out goes, which depends on whether this run holds a search tool. + * + * Asked to "search for" something in a demo, a Bot drove its browser to a search engine instead of + * calling the search tool it held. The prompt invited it: it said to navigate whenever asked to + * "look at, check or read" anything and to "never claim you cannot browse", and the one sentence + * saying a page is not a search sat beside them as a hedge. A model reading both picks the emphatic + * one. So the Bot is told one thing, chosen by what it holds, instead of two things to reconcile. + * + * WITH A SEARCH TOOL, finding information is the search tool's job, and the computer is kept for what + * only a browser can do. The research guidance generated from the grants says how to use the tool; + * this says only that the browser is not it. + * + * WITHOUT ONE, the browser is still not a search tool. A Bot typing a query into a search engine + * meets cookie walls and CAPTCHAs, reads results pages built for people, and makes the person watch + * it. It answers from what it knows, marked as unchecked as `PROVENANCE_GUIDANCE` asks, and offers a + * search app with the card tool, because "I cannot search" with no way to fix it is the same dead end + * a missing app used to be. The names are the catalogue's search apps, since the card resolves an + * app by name and there is no app called "search"; see `RESEARCH_APP_NAMES`. + */ +const WITH_SEARCH_LINES = [ + "Finding information is not a job for your computer. When you are asked to search for something,", + "look something up, find the latest on a subject or find a few examples of something, use your", + "search tool and answer from what it returns. Do not open a search engine in your browser.", + "Use the computer for what a search tool cannot do: signing in, clicking through a site, filling in", + "a form, or opening a page the person asked to see.", +]; + +const WITHOUT_SEARCH_LINES = [ + "Opening a page is not a web search, and you do not hold a search tool. NEVER drive your browser", + "to a search engine (Google, Bing, DuckDuckGo or any other) to look something up, and do not guess", + "at addresses hoping one of them has the answer.", + "When you are asked to search for something, look something up, find the latest on a subject or", + "find a few examples of something, answer from what you already know and say plainly that you have", + "not checked it against a source.", + `If you hold ${SHOW_APP_ACCESS_TOOL}, call it once in the conversation with the name of one search`, + `app (${RESEARCH_APP_NAMES.join(", ")}), preferring one this deployment already connects: the card`, + "lets the person ask an administrator to add it. Otherwise say an administrator can add a search", + "app.", + "Asked whether you can search the web, say you can open and read pages the person names but cannot", + "search.", +]; + /** * The order of operations that makes the computer tools usable. * * The prompt requires snapshot-first computer use. Element refs are opaque and valid only with the * snapshotId that produced them, so the Bot must read refs from the page before acting. */ -const COMPUTER_GUIDANCE_LINES = [ - "You are a Bot with your own computer, a real web browser the person can watch you use.", - "When you are asked to look at, open, visit, check or read a web page, call computer_navigate.", - "Never claim you cannot browse: opening a page is something you can actually do.", - "Opening a page is not a web search. To find sources rather than read a page you can name, use a", - "search tool if you hold one; if you do not, say you can open and read pages but cannot search.", - "Opening a page returns its title and its readable text. Answer from that text.", - "Never tell the person to go and look at the page themselves: you have already read it.", - "", +const ACTING_LINES = [ "You can also ACT on a page: fill in forms, click buttons, tick boxes and follow links.", "The order matters. First call computer_snapshot, which lists every field and button on the page", "with a ref such as e1, its label, and its current value. Then call computer_type and", @@ -69,22 +115,55 @@ const COMPUTER_GUIDANCE_LINES = [ ]; /** - * `COMPUTER_GUIDANCE_LINES` uses `""` as a paragraph break. Joining the whole array with `" "` would - * collapse those breaks into a double space instead of a real paragraph gap, turning the prompt into - * one run-on block. Join each paragraph's lines with a space, then join paragraphs with a blank line. + * These line arrays use `""` as a paragraph break. Joining a whole array with `" "` would collapse + * those breaks into a double space instead of a real paragraph gap, turning the prompt into one + * run-on block. Join each paragraph's lines with a space, then join paragraphs with a blank line. */ -export const COMPUTER_GUIDANCE = COMPUTER_GUIDANCE_LINES.reduce( - (paragraphs, line) => { - if (line === "") { - paragraphs.push(""); - return paragraphs; - } - const last = paragraphs.length - 1; - paragraphs[last] = paragraphs[last] ? `${paragraphs[last]} ${line}` : line; - return paragraphs; - }, - [""], -).join("\n\n"); +function asParagraphs(lines: readonly string[]): string { + return lines + .reduce( + (paragraphs, line) => { + if (line === "") { + paragraphs.push(""); + return paragraphs; + } + const last = paragraphs.length - 1; + paragraphs[last] = paragraphs[last] + ? `${paragraphs[last]} ${line}` + : line; + return paragraphs; + }, + [""], + ) + .join("\n\n"); +} + +/** What a run holds that changes what it is told about its computer. */ +export type ComputerHolding = { + /** Whether this run was offered a tool that finds sources, from any research provider. */ + search: boolean; +}; + +/** + * The computer guidance for one run, chosen by what it holds. + * + * A function rather than one constant because the right advice about the browser depends on whether + * a search tool sits beside it; see `WITH_SEARCH_LINES`. Built-in Bots get it from `copilot.ts`, which + * knows the tools it is building with; `agent-bot` and `agent-langgraph` work it out from the tools + * each run offers them. + */ +export function computerGuidance(holding: ComputerHolding): string { + return asParagraphs([ + ...OPENING_A_PAGE_LINES, + "", + ...(holding.search ? WITH_SEARCH_LINES : WITHOUT_SEARCH_LINES), + "", + ...ACTING_LINES, + ]); +} + +/** How a deployment with a computer words it for a run. See {@link computerGuidance}. */ +export type ComputerGuidance = typeof computerGuidance; /** * Where an answer came from, said out loud. @@ -133,18 +212,7 @@ const PROVENANCE_GUIDANCE_LINES = [ "honest, and a search that never ends is a Bot that never answers.", ]; -export const PROVENANCE_GUIDANCE = PROVENANCE_GUIDANCE_LINES.reduce( - (paragraphs, line) => { - if (line === "") { - paragraphs.push(""); - return paragraphs; - } - const last = paragraphs.length - 1; - paragraphs[last] = paragraphs[last] ? `${paragraphs[last]} ${line}` : line; - return paragraphs; - }, - [""], -).join("\n\n"); +export const PROVENANCE_GUIDANCE = asParagraphs(PROVENANCE_GUIDANCE_LINES); /** * What a tool call is given when its answer never came. From 494cf85cf3d48e76311138391892293a28fe0f55 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:21:44 -0300 Subject: [PATCH 12/73] Say in computer_navigate's description that it is not a web search The tool description repeated the old prompt's "look at, visit, open or check a website", which reads "check the latest on X" as a page to open. It now says to open a particular site and never a search engine, so the tool list agrees with the guidance beside it. --- app/src/lib/copilot/computer-tools.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index 2e6d2ccc64..524d2336d8 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -199,9 +199,10 @@ export function ComputerTools() { available: offered.navigate, name: "computer_navigate", description: - "Open a web page on your own computer so the person can watch. Use this when asked to look " + - "at, visit, open or check a website. Returns the page title and its readable text, so answer " + - "from what comes back rather than telling the person to go and look.", + "Open a web page on your own computer so the person can watch. Use this when asked to open, " + + "visit or read a particular website. It is not a web search: never open a search engine with " + + "it to look something up. Returns the page title and its readable text, so answer from what " + + "comes back rather than telling the person to go and look.", parameters: z.object({ url: z.string().describe("Full web address to open, including https://"), }), From 93f4ab7db6a4d24479da60fb0834fd5bbfb40bca Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:25:11 -0300 Subject: [PATCH 13/73] Format the not-ready screenshot route tests --- server/tests/computer-routes.test.ts | 34 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/server/tests/computer-routes.test.ts b/server/tests/computer-routes.test.ts index c7a6f50667..705d4ad1fe 100644 --- a/server/tests/computer-routes.test.ts +++ b/server/tests/computer-routes.test.ts @@ -69,25 +69,25 @@ describe("a computer that is not ready yet", () => { ); } - test.each([ - "absent", - "starting", - ] as const)("answers %s as a state, without taking a screenshot", async (state) => { - let screenshots = 0; - const routes = routesFor({ - status: async (botId: string) => ({ botId, state }), - screenshot: async () => { - screenshots++; - return { base64: "AAAA", width: 1, height: 1, capturedAt: "" }; - }, - }); + test.each(["absent", "starting"] as const)( + "answers %s as a state, without taking a screenshot", + async (state) => { + let screenshots = 0; + const routes = routesFor({ + status: async (botId: string) => ({ botId, state }), + screenshot: async () => { + screenshots++; + return { base64: "AAAA", width: 1, height: 1, capturedAt: "" }; + }, + }); - const response = await routes.request("http://openbot.test/b/screenshot"); + const response = await routes.request("http://openbot.test/b/screenshot"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ ready: false, state }); - expect(screenshots).toBe(0); - }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ready: false, state }); + expect(screenshots).toBe(0); + }, + ); test("passes an unreachable computer's reason through, still as an answer", async () => { const routes = routesFor({ From d549fd331ee7de31daf45eebd6e053cdaff12a6f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:25:11 -0300 Subject: [PATCH 14/73] Type the still-starting headless test's fetch double --- server/tests/headless-tools.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tests/headless-tools.test.ts b/server/tests/headless-tools.test.ts index ba4acfdd6c..6f71853e38 100644 --- a/server/tests/headless-tools.test.ts +++ b/server/tests/headless-tools.test.ts @@ -418,9 +418,9 @@ test("a computer still starting answers 'not performed, try again' rather than t }, }, policy: () => ({ mode: "enforce", allow: ["true"], deny: [] }), - fetchImpl: async () => { + fetchImpl: (async () => { throw new Error("nothing should reach the computer"); - }, + }) as unknown as typeof fetch, }); const tools = headless.createHeadlessComputerTools({ gateway, From dbb4b86a436e74fe08d586c5f7574c87e218641f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:25:11 -0300 Subject: [PATCH 15/73] Report a live screen being retried as reconnecting, not as a problem --- app/src/components/computer/live-screen.tsx | 6 ++- app/src/lib/computers/screen-connection.ts | 16 +++++- app/tests/screen-connection.test.ts | 57 ++++++++++++++++++++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/app/src/components/computer/live-screen.tsx b/app/src/components/computer/live-screen.tsx index b4e7a38228..fe8cd543ac 100644 --- a/app/src/components/computer/live-screen.tsx +++ b/app/src/components/computer/live-screen.tsx @@ -92,6 +92,8 @@ type Props = { driving: boolean; /** Called with a human-readable reason when the stream cannot be established. */ onProblem?: (problem: string | null) => void; + /** Called while a dropped stream is being retried on its own, and again when it is back or gone. */ + onReconnecting?: (reconnecting: boolean) => void; retryKey?: number; }; @@ -105,6 +107,7 @@ export function LiveScreen({ computerId, driving, onProblem, + onReconnecting, retryKey = 0, }: Props) { const canvasRef = useRef(null); @@ -279,6 +282,7 @@ export function LiveScreen({ setConnected(ready); }, onProblem: (problem) => onProblem?.(problem), + onReconnecting: (reconnecting) => onReconnecting?.(reconnecting), onDisconnect: () => { frameSize.current = null; latestFrame.current = null; @@ -296,7 +300,7 @@ export function LiveScreen({ document.removeEventListener("visibilitychange", drawLatestFrame); socketRef.current = null; }; - }, [computerId, onProblem, retryKey]); + }, [computerId, onProblem, onReconnecting, retryKey]); useEffect(() => { const wasDriving = previouslyDriving.current; diff --git a/app/src/lib/computers/screen-connection.ts b/app/src/lib/computers/screen-connection.ts index f540144f12..9a716f11dd 100644 --- a/app/src/lib/computers/screen-connection.ts +++ b/app/src/lib/computers/screen-connection.ts @@ -11,6 +11,16 @@ type Options = { verifyOwnership: () => Promise; onReady: (ready: boolean) => void; onProblem: (problem: string | null) => void; + /** + * Whether a dropped stream is being retried on its own. + * + * Separate from `onProblem` on purpose. A retry in progress is routine (a deploy, a laptop waking, + * a load balancer recycling a connection) and is drawn as a small badge over the screen. A problem + * is what is left once the retries are spent, and only that gets the overlay with a Retry button. + * Both used to arrive through `onProblem`, so every brief blip covered the screen with the same + * panel a dead connection gets. + */ + onReconnecting?: (reconnecting: boolean) => void; onDisconnect: () => void; onMessage: (event: { data: unknown }) => void; schedule?: (run: () => void, delay: number) => () => void; @@ -40,12 +50,13 @@ export function connectScreen(options: Options) { options.onReady(false); const delay = BACKOFF[attempts++]; if (delay === undefined) { + options.onReconnecting?.(false); options.onProblem( "The live screen is disconnected. Retry to reconnect.", ); return; } - options.onProblem("Reconnecting to the live screen…"); + options.onReconnecting?.(true); cancelTimer = schedule(connect, delay); }; current.onopen = async () => { @@ -53,6 +64,7 @@ export function connectScreen(options: Options) { const permitted = attempts === 0 || (await options.verifyOwnership().catch(() => false)); if (!owns()) return; + options.onReconnecting?.(false); options.onProblem(null); options.onReady(permitted); }; @@ -73,6 +85,7 @@ export function connectScreen(options: Options) { ) { stopped = true; cancelTimer?.(); + options.onReconnecting?.(false); options.onDisconnect(); options.onReady(false); options.onProblem( @@ -99,6 +112,7 @@ export function connectScreen(options: Options) { stop: () => { stopped = true; cancelTimer?.(); + options.onReconnecting?.(false); options.onReady(false); socket?.close(); }, diff --git a/app/tests/screen-connection.test.ts b/app/tests/screen-connection.test.ts index 42a0a79d76..502a1ec3cf 100644 --- a/app/tests/screen-connection.test.ts +++ b/app/tests/screen-connection.test.ts @@ -55,7 +55,8 @@ test("connection loss disables input, retries boundedly, then asks for Retry", a const r = rig(); r.sockets[0]?.onopen?.(); r.sockets[0]?.onclose?.(); - expect(r.statuses.at(-2)).toBe("blocked"); + // Blocked, and nothing more: a retry in progress is not a problem (see the reconnecting test). + expect(r.statuses.at(-1)).toBe("blocked"); for (let i = 0; i < 5; i++) { r.timers[i]?.run(); r.sockets[i + 1]?.onclose?.(); @@ -107,6 +108,60 @@ test("a superseded viewer stays stopped until explicit Retry", () => { r.control.stop(); }); +/* + * A blip is a badge, not an overlay. Every brief drop used to report "Reconnecting to the live + * screen…" as a problem, and the view drew problems as a panel over the screen with a Retry button, + * so a routine reconnect looked exactly like a dead connection. + */ +test("a drop being retried reports reconnecting, and no problem until the retries run out", async () => { + const sockets: ScreenSocket[] = []; + const timers: (() => void)[] = []; + const problems: (string | null)[] = []; + const reconnecting: boolean[] = []; + connectScreen({ + open: () => { + const socket: ScreenSocket = { + onopen: null, + onclose: null, + onerror: null, + onmessage: null, + close: () => undefined, + }; + sockets.push(socket); + return socket; + }, + schedule: (run) => { + timers.push(run); + return () => undefined; + }, + verifyOwnership: async () => false, + onReady: () => undefined, + onProblem: (problem) => problems.push(problem), + onReconnecting: (value) => reconnecting.push(value), + onDisconnect: () => undefined, + onMessage: () => undefined, + }); + + await sockets[0]?.onopen?.(); + sockets[0]?.onclose?.(); + expect(reconnecting.at(-1)).toBe(true); + expect(problems.filter(Boolean)).toEqual([]); + + // Back on the first retry: the badge goes, and nothing was ever drawn over the screen. + timers[0]?.(); + await sockets[1]?.onopen?.(); + expect(reconnecting.at(-1)).toBe(false); + expect(problems.filter(Boolean)).toEqual([]); + + // Gone for good: only now is it a problem, and the badge is cleared with it. + for (let i = 1; i <= 6; i++) { + sockets[i]?.onclose?.(); + timers[i]?.(); + } + expect(reconnecting.at(-1)).toBe(false); + expect(problems.at(-1)).toContain("Retry"); +}); + test("unmount cancels a pending reconnect and ignores old socket callbacks", () => { const r = rig(); r.sockets[0]?.onclose?.(); From 959a45b84510238168cc23ff4792d8a7ba860e85 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:25:11 -0300 Subject: [PATCH 16/73] Show a computer starting calmly, and a problem only when there is one --- app/src/components/computer/computer-view.tsx | 153 ++++++++++- app/src/lib/computers/queries.ts | 34 +++ app/src/lib/computers/screen.ts | 44 ++- app/tests/computer-calm-start.test.tsx | 253 ++++++++++++++++++ .../computer-preview-visibility.test.tsx | 22 +- 5 files changed, 497 insertions(+), 9 deletions(-) create mode 100644 app/tests/computer-calm-start.test.tsx diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx index 4fc93e71b6..8e1395da23 100644 --- a/app/src/components/computer/computer-view.tsx +++ b/app/src/components/computer/computer-view.tsx @@ -1,6 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { supplySecret } from "@/lib/computers/control"; +import { + type ComputerState, + computerStatusQueryOptions, +} from "@/lib/computers/queries"; import { readPageFrame, readScreenshot, @@ -85,6 +90,23 @@ async function preloadFrame(base64: string): Promise { } } +function capitalise(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +/** + * How long a computer may go without a frame before that is worth telling anybody. + * + * GENEROUS ON PURPOSE. A first start pulls an image, schedules a pod and launches a browser, and a + * minute is ordinary. The panel used to call the first frameless poll a problem, so every cold start + * opened on "Can't show the screen right now… ask an admin", in front of a person whose computer was + * doing exactly what it should. Past the server's own wait for a cold start, with room to spare. + */ +const STARTUP_GRACE_MS = 150_000; + +/** How often a computer that has not drawn a frame yet is asked where it is. */ +const STATUS_POLL_MS = 2_000; + /** Identical frames in a row that mean the page has stopped changing. */ const SETTLED_FRAMES = 3; @@ -107,9 +129,22 @@ function NothingToSee({ blankBrowser, settled, page, + waiting, + owner = "the Bot's", }: { problem: string | null; blankBrowser: boolean; + /** + * Why there is no picture yet, when the reason is ordinary. + * + * `starting` is a computer on its way up, which on a first start takes about a minute; `asleep` is + * one that is off and starts on the Bot's next action. Neither is a problem and neither is drawn as + * one. A problem is only what is left after a generous wait, or a computer the server says it + * cannot reach. + */ + waiting?: "starting" | "asleep" | undefined; + /** Whose computer, possessive and lower case: "Sales's", or "the Bot's" without a name. */ + owner?: string; /** Whether this is a turn that has finished, rather than the browser as it is now. */ settled?: boolean; /** The page that turn opened, named when there is no picture of it. */ @@ -157,6 +192,15 @@ function NothingToSee({ to check its Computer. + ) : waiting === "starting" ? ( + + Starting {owner} computer. The first start takes about a minute. + + ) : waiting === "asleep" ? ( + + {capitalise(owner)} computer is off. It starts when the Bot next uses + it. + ) : blankBrowser ? ( The Bot hasn't opened a page yet. ) : ( @@ -202,6 +246,8 @@ type Props = { * side panel is not a turn and has nothing to remember. */ toolCallId?: string; + /** How long to wait for a first frame before calling it a problem. Shortened by tests. */ + startupGraceMs?: number; }; export function ComputerView({ @@ -215,11 +261,21 @@ export function ComputerView({ page, finished, toolCallId, + startupGraceMs = STARTUP_GRACE_MS, }: Props) { const [shot, setShot] = useState(null); + /** + * The last screenshot that was attempted and failed. Held, not shown: see `shownProblem` below. + */ const [problem, setProblem] = useState(null); + /** What the last poll said about a computer that is not ready, which it says without waking it. */ + const [polledState, setPolledState] = useState(null); + /** Set once a computer has gone `startupGraceMs` without a frame. */ + const [stalled, setStalled] = useState(false); const [expanded, setExpanded] = useState(false); const [streamProblem, setStreamProblem] = useState(null); + /** A dropped live stream being retried on its own. A badge, never the overlay. */ + const [reconnecting, setReconnecting] = useState(false); const [retryKey, setRetryKey] = useState(0); /** * A finished turn is history, and history is not polled. @@ -339,10 +395,17 @@ export function ComputerView({ // Always fetch at least one frame; only repeated refreshes are conditional. const tick = async () => { try { - const { frame, error } = await readScreenshot(computerId); + const { frame, error, notReady } = await readScreenshot(computerId); if (generation.current !== mine) return; - if (!frame) { + if (notReady) { + /* + * Asleep or starting, said by the server without waking the computer. Not a problem: + * nothing has failed, so the last failure (if any) is no longer the news. + */ + setPolledState(notReady.state); + setProblem(null); + } else if (!frame) { setProblem(error ?? "The screen isn't available right now."); } else { // Exact byte comparison is the settling signal. @@ -353,6 +416,7 @@ export function ComputerView({ if (generation.current !== mine) return; setShot(frame); setProblem(null); + setPolledState(null); } } finally { if (generation.current === mine && shouldContinue()) { @@ -368,6 +432,38 @@ export function ComputerView({ }; }, [computerId, active, intervalMs, secretPending, settled, visualVisible]); + /* + * Where the computer is in its life, until there is a picture to show. + * + * The status route never wakes a computer, so this is safe to poll while one is starting. It is + * what the panel knows before its first screenshot has come back, and the only word on a computer + * the server cannot reach. Once a frame arrives the frame is the answer and this stops asking, and + * so it does while the screenshot poll is itself reporting the state, which it does every second. + * A failed status read is "not known yet", never a problem in itself. + */ + const status = useQuery({ + ...computerStatusQueryOptions(computerId), + enabled: !settled && visualVisible && shot === null && polledState === null, + refetchInterval: STATUS_POLL_MS, + retry: false, + }); + /** The fresher of the two answers: the poll that just ran, else the status read. */ + const computerState: ComputerState | undefined = + polledState ?? status.data?.state; + /** In a turn, an absent computer is one the Bot's action is about to start, not one left off. */ + const asleep = computerState === "absent" && !toolCallId; + /** Waiting for a first frame, for a reason that can end. A computer that is off waits for the Bot. */ + const waitingForFrame = !settled && visualVisible && shot === null && !asleep; + + useEffect(() => { + if (!waitingForFrame) { + setStalled(false); + return; + } + const timer = setTimeout(() => setStalled(true), startupGraceMs); + return () => clearTimeout(timer); + }, [waitingForFrame, startupGraceMs]); + // Input forwarding lives in LiveScreen on the socket. // Escape is bound to the window so it works regardless of overlay focus. useEffect(() => { @@ -386,6 +482,36 @@ export function ComputerView({ */ const blankBrowser = !settled && shot ? isBlankBrowser(shot) : false; + const owner = name ? `${name}'s` : "the Bot's"; + /** + * The problem worth showing, which is not the same as the last one that happened. + * + * A computer the server says it cannot reach is a problem at once. Anything else that failed is + * shown only once the wait has run past what a start can take, because before then a failed + * screenshot is far more often a browser still launching than a broken computer. + */ + const shownProblem = + computerState === "unreachable" + ? `${capitalise(owner)} computer can't be reached right now.` + : stalled + ? (problem ?? "It's taking longer than usual to start.") + : null; + /** + * The calm sentence, when there is no picture and nothing wrong. + * + * A ready computer that has simply not sent its first frame yet keeps the plain "Waiting for the + * Bot's screen…", and so does one whose state is not known yet: saying "Starting" over a computer + * that is already running would be the panel inventing a delay. A failed screenshot from a computer + * that is not known to be ready is read as one still coming up. + */ + const waiting: "starting" | "asleep" | undefined = asleep + ? "asleep" + : computerState === "starting" || + computerState === "absent" || + (problem !== null && computerState !== "ready") + ? "starting" + : undefined; + /* * Sized from the ratio, never from the payload, so the frame is identical while a screen is * loading, once it arrives, and while the browser has nothing open. A blank browser used to @@ -474,9 +600,11 @@ export function ComputerView({ {showScreen ? null : ( )} @@ -651,8 +779,23 @@ export function ComputerView({ computerId={computerId} driving={driving} onProblem={setStreamProblem} + onReconnecting={setReconnecting} retryKey={retryKey} /> + {/* + A drop being retried, said quietly in a corner. The stream retries on its + own with a backoff, and the screen under it is usually back in a second; + covering it with the Retry panel made a routine reconnect look like a dead + one. The panel below is for when the retries have run out. + */} + {reconnecting && !streamProblem ? ( + + Reconnecting… + + ) : null} {/* A live screen that ends reports why through `onProblem`, and this is the branch that is mounted when it does. Without drawing it here the message @@ -679,9 +822,11 @@ export function ComputerView({

)} diff --git a/app/src/lib/computers/queries.ts b/app/src/lib/computers/queries.ts index 7d8d8dcffa..8f777755f3 100644 --- a/app/src/lib/computers/queries.ts +++ b/app/src/lib/computers/queries.ts @@ -40,12 +40,46 @@ export type ActionPolicy = { export type RuleLabel = { name: string; description: string }; +/** + * Where one Bot's computer is in its life, as the server's provider reports it. + * + * `absent` is a computer that is not running: never started, or put to sleep after a quiet spell. + * Either way it starts on the Bot's next computer action, so it is not a fault. `starting` is on its + * way up. `unreachable` is the only one that is something wrong. + */ +export type ComputerState = "absent" | "starting" | "ready" | "unreachable"; + +export type ComputerStatus = { + botId: string; + state: ComputerState; + /** Set when `state` is `unreachable`. Written for an admin, so not shown on a screen anybody can open. */ + reason?: string; +}; + export const computerKeys = { all: ["computers"] as const, fleet: () => ["computers", "fleet"] as const, policy: () => ["computers", "policy"] as const, + status: (botId: string) => ["computers", "status", botId] as const, }; +/** + * One Bot's computer's state, read without waking it. + * + * The status route reads the provider's own record of the computer and never dials it, which is what + * makes it safe to poll while somebody is watching a computer start: asking for a screenshot instead + * went through the path that resumes a sleeping computer, so looking was enough to start it. + */ +export function computerStatusQueryOptions(botId: string) { + return queryOptions({ + queryKey: computerKeys.status(botId), + queryFn: (): Promise => + client(`/api/computers/${encodeURIComponent(botId)}/status`, { + fallback: "Couldn't check the computer.", + }).then((response) => response.json()), + }); +} + /** * The deployment-wide fleet route. * diff --git a/app/src/lib/computers/screen.ts b/app/src/lib/computers/screen.ts index d52608af90..a8117b0139 100644 --- a/app/src/lib/computers/screen.ts +++ b/app/src/lib/computers/screen.ts @@ -1,4 +1,5 @@ import { tryClient } from "@/lib/client"; +import type { ComputerState } from "./queries"; /** * One frame of a Bot's screen. @@ -16,6 +17,26 @@ export type Screenshot = { url?: string; }; +/** + * What a screenshot request can come back with. + * + * `notReady` is not an error. The server answers it for a computer that is asleep or starting, and + * does so without waking it; a computer in either state is behaving normally, so the caller draws it + * calmly and keeps polling. `error` is a screenshot that was attempted and failed. + */ +export type ScreenshotRead = { + frame?: Screenshot; + error?: string; + notReady?: { state: ComputerState; reason?: string }; +}; + +const COMPUTER_STATES: readonly ComputerState[] = [ + "absent", + "starting", + "ready", + "unreachable", +]; + /** * Read the current frame. * @@ -25,7 +46,7 @@ export type Screenshot = { */ export async function readScreenshot( computerId: string, -): Promise<{ frame?: Screenshot; error?: string }> { +): Promise { const unavailable = "The screen is not available right now."; try { const response = await tryClient(`/api/computers/${computerId}/screenshot`); @@ -35,12 +56,31 @@ export async function readScreenshot( } | null; return { error: body?.error ?? unavailable }; } - return parseScreenshot(await response.json().catch(() => null)); + const body = (await response.json().catch(() => null)) as unknown; + const notReady = notReadyOf(body); + if (notReady) return { notReady }; + return parseScreenshot(body); } catch { return { error: unavailable }; } } +/** The server's "not ready yet" answer, or nothing when the body is something else. */ +function notReadyOf(body: unknown): ScreenshotRead["notReady"] { + if (!body || typeof body !== "object" || Array.isArray(body)) return; + const { ready, state, reason } = body as { + ready?: unknown; + state?: unknown; + reason?: unknown; + }; + if (ready !== false) return; + if (!COMPUTER_STATES.includes(state as ComputerState)) return; + return { + state: state as ComputerState, + ...(typeof reason === "string" ? { reason } : {}), + }; +} + function parseScreenshot(body: unknown): { frame?: Screenshot; error?: string; diff --git a/app/tests/computer-calm-start.test.tsx b/app/tests/computer-calm-start.test.tsx new file mode 100644 index 0000000000..84f8c48cbf --- /dev/null +++ b/app/tests/computer-calm-start.test.tsx @@ -0,0 +1,253 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; +import type { ReactElement } from "react"; +import { ComputerView } from "@/components/computer/computer-view"; + +/** + * A computer starting in front of a person never reads as a failure. + * + * The panel used to call the first frameless screenshot a problem, so every cold start opened on + * "Can't show the screen right now… ask an admin", and every blip in the live stream covered the + * screen with a Retry panel. What a person should see instead is a sentence saying the computer is + * starting, a small "Reconnecting…" while the stream comes back, and a problem only when there + * really is one. + */ + +class SocketDouble { + static readonly OPEN = 1; + static readonly CLOSED = 3; + static all: SocketDouble[] = []; + + readyState = SocketDouble.OPEN; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + + constructor(_url: string) { + SocketDouble.all.push(this); + queueMicrotask(() => this.onopen?.()); + } + + send() {} + + close() { + this.readyState = SocketDouble.CLOSED; + } +} + +let originalFetch: typeof fetch; +let originalWebSocket: typeof WebSocket; +let originalIntersectionObserver: typeof IntersectionObserver | undefined; + +beforeAll(() => { + GlobalRegistrator.register(); + originalFetch = globalThis.fetch; + originalWebSocket = globalThis.WebSocket; + originalIntersectionObserver = globalThis.IntersectionObserver; + globalThis.WebSocket = SocketDouble as unknown as typeof WebSocket; + // Without an observer the preview counts as on screen, which is what these cases are about. + Reflect.deleteProperty(globalThis, "IntersectionObserver"); + HTMLImageElement.prototype.decode = () => Promise.resolve(); +}); + +afterEach(() => { + cleanup(); + SocketDouble.all = []; + globalThis.fetch = originalFetch; +}); + +afterAll(() => { + globalThis.fetch = originalFetch; + globalThis.WebSocket = originalWebSocket; + if (originalIntersectionObserver) { + globalThis.IntersectionObserver = originalIntersectionObserver; + } + GlobalRegistrator.unregister(); +}); + +function renderWithQueries(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} + +const control = { + holder: "bot", + since: new Date(0).toISOString(), + requested: false, +}; + +/** A server whose answers are given per path ending; anything else is a 404. */ +function serve(routes: Record Response>) { + globalThis.fetch = (async (input) => { + const path = String(input); + for (const [ending, answer] of Object.entries(routes)) { + if (path.endsWith(ending)) return answer(); + } + if (path.endsWith("/control")) return Response.json(control); + return new Response("not found", { status: 404 }); + }) as typeof fetch; +} + +const frame = () => + Response.json({ + base64: "AAAA", + width: 320, + height: 200, + capturedAt: new Date(0).toISOString(), + url: "https://example.com", + }); + +test("a computer that is starting says so, calmly, and shows no problem", async () => { + serve({ + "/status": () => Response.json({ botId: "sales", state: "starting" }), + "/screenshot": () => Response.json({ ready: false, state: "starting" }), + }); + + const view = renderWithQueries( + , + ); + + expect( + await view.findByText( + "Starting Sales's computer. The first start takes about a minute.", + ), + ).toBeDefined(); + expect(view.queryByText(/can't show the screen/i)).toBeNull(); + expect(view.queryByText(/error/i)).toBeNull(); +}); + +test("failed screenshots during a start are not a problem until the grace runs out", async () => { + serve({ + "/status": () => Response.json({ botId: "sales", state: "starting" }), + "/screenshot": () => + Response.json( + { error: "The computer did not respond." }, + { status: 503 }, + ), + }); + + const view = renderWithQueries( + , + ); + + await view.findByText(/^Starting Sales's computer/); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + }); + expect(view.queryByText(/can't show the screen/i)).toBeNull(); + + // Past the grace, with still no frame, it is worth saying. + expect( + await view.findByText( + "Can't show the screen right now", + {}, + { timeout: 1000 }, + ), + ).toBeDefined(); + expect(view.getByText("The computer did not respond.")).toBeDefined(); +}); + +test("a computer the server cannot reach is a problem straight away", async () => { + serve({ + "/status": () => + Response.json({ + botId: "sales", + state: "unreachable", + reason: "supervisor down", + }), + "/screenshot": () => + Response.json({ + ready: false, + state: "unreachable", + reason: "supervisor down", + }), + }); + + const view = renderWithQueries( + , + ); + + expect( + await view.findByText("Sales's computer can't be reached right now."), + ).toBeDefined(); + // The provider's own words are for an admin, not this screen. + expect(view.queryByText(/supervisor down/)).toBeNull(); +}); + +test("a computer that is off, watched from the side panel, says it starts when the Bot uses it", async () => { + serve({ + "/status": () => Response.json({ botId: "sales", state: "absent" }), + "/screenshot": () => Response.json({ ready: false, state: "absent" }), + }); + + const view = renderWithQueries( + , + ); + + expect( + await view.findByText( + "Sales's computer is off. It starts when the Bot next uses it.", + ), + ).toBeDefined(); + // Off is not stalled: waiting past the grace still shows no problem. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 80)); + }); + expect(view.queryByText(/can't show the screen/i)).toBeNull(); +}); + +test("a live stream that drops shows a quiet badge, not the Retry panel", async () => { + HTMLCanvasElement.prototype.getContext = (() => ({ + drawImage: () => undefined, + clearRect: () => undefined, + })) as unknown as typeof HTMLCanvasElement.prototype.getContext; + serve({ "/screenshot": frame }); + + const view = renderWithQueries( + , + ); + await view.findByAltText("What the Bot is looking at"); + fireEvent.click(view.getByLabelText("Open the Bot's screen full size")); + await waitFor(() => expect(SocketDouble.all.length).toBe(1)); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await act(async () => { + SocketDouble.all[0]?.onclose?.(); + }); + + expect(view.getByText("Reconnecting…")).toBeDefined(); + expect(view.queryByText("Retry")).toBeNull(); +}); diff --git a/app/tests/computer-preview-visibility.test.tsx b/app/tests/computer-preview-visibility.test.tsx index c9d727a96f..b4edecea75 100644 --- a/app/tests/computer-preview-visibility.test.tsx +++ b/app/tests/computer-preview-visibility.test.tsx @@ -1,5 +1,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { ComputerView } from "@/components/computer/computer-view"; import { LiveScreen } from "@/components/computer/live-screen"; @@ -137,6 +139,16 @@ afterAll(() => { GlobalRegistrator.unregister(); }); +/** The view reads the computer's state through a query, so it needs a client of its own per test. */ +function renderWithQueries(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} + function setVisibility(state: DocumentVisibilityState) { Object.defineProperty(document, "visibilityState", { configurable: true, @@ -264,7 +276,9 @@ test("hidden computer preview pauses screenshot polling while control polling co return new Response("not found", { status: 404 }); }) as typeof fetch; - render(); + renderWithQueries( + , + ); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 30)); }); @@ -297,7 +311,7 @@ test("offscreen computer preview starts screenshot polling when it intersects", return new Response("not found", { status: 404 }); }) as typeof fetch; - render( + renderWithQueries( , ); const entry = IntersectionObserverDouble.observed.at(0); @@ -341,7 +355,9 @@ test("computer preview does not decode a screenshot response that lands after th return new Response("not found", { status: 404 }); }) as typeof fetch; - render(); + renderWithQueries( + , + ); const entry = IntersectionObserverDouble.observed.at(0); expect(entry).toBeDefined(); await act(async () => { From e95a2f838a5ee3090f7b3d7d2132489429864956 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:26:05 -0300 Subject: [PATCH 17/73] Say what a conversation is waiting on, on reads and on every busy event The busy signal said only that something was running, on the socket and nowhere else. Read what a conversation waits on from the rows that already say so: an unfinished hop it asked, folded with its relay, and an open routine run. The channel read and the roster carry the list, and every busy event carries the whole list at the moment it is sent. --- server/src/channels/events.ts | 10 + server/src/channels/routes.ts | 146 ++++++-- server/src/channels/working.ts | 226 +++++++++++ server/tests/channel-routes.test.ts | 89 ++++- server/tests/channel-work.integration.test.ts | 353 ++++++++++++++++++ 5 files changed, 782 insertions(+), 42 deletions(-) create mode 100644 server/src/channels/working.ts create mode 100644 server/tests/channel-work.integration.test.ts diff --git a/server/src/channels/events.ts b/server/src/channels/events.ts index ebdd596653..c4c2dc6466 100644 --- a/server/src/channels/events.ts +++ b/server/src/channels/events.ts @@ -1,4 +1,5 @@ import postgres from "postgres"; +import type { ChannelWorkWire } from "./working"; /** * Live channel activity, from whoever ran an agent to everybody else in the channel. @@ -45,6 +46,15 @@ export type ChannelActivityEvent = { * streams. A missed one costs at most a stuck-looking dot until the next real event, never data. */ busy?: boolean; + /** + * Everything this channel is waiting on right now: a Bot it asked, a routine firing into it. + * Absent on an ordinary activity event. + * + * The WHOLE list, never a change to it, so the newest event is the answer and a missed one is + * corrected by the next. Read from the queue and the run history at the moment of sending, which + * is also what the roster and the channel read return. See `working.ts`. + */ + work?: ChannelWorkWire[]; }; /** "The roster you hold may be wrong." Carries no delta, because what was lost is not recoverable. */ diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index 2798aca78a..dbc855c1b6 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -37,6 +37,7 @@ import { import { upgradeWebSocket } from "./socket"; import { oneLine } from "./text"; import type { ThreadIdentity } from "./thread-identity"; +import { type ChannelWork, channelWorkDto, workIn } from "./working"; export type AgentChannel = { id: string; @@ -235,6 +236,17 @@ export type ChannelStore = { channelId: string, busy: boolean, ): Promise; + /** + * Tell a channel's members what it is waiting on now, without saying a turn started or ended. + * + * For the moments the queue moves and no turn does: a hop finished, released for another try, or + * turned into a relay. Every busy signal carries the same list, so this is only for those. + */ + signalWork(threadId: string): Promise; + /** What each of these channels is waiting on. See `workIn` in working.ts. */ + workIn( + channels: readonly { id: string; threadId: string }[], + ): Promise>; }; const PRIVATE_AGENT_CHANNEL_DESCRIPTION = "Private agent channel."; @@ -319,6 +331,44 @@ export function createChannelStore( return { id, name, agentIds, threadId, active: true, lastMessageAt: null }; }; + /** + * Tell a channel's members what it is waiting on, and whether a turn just started or ended. + * + * No table write: busy is a moment, and the rows the work list is read from stay the source of + * truth. Just the announcement, carrying the members the same way recordActivity does. The list + * rides on every one of these so a client never merges a delta into what it holds: the newest + * event is the whole answer, and the same answer a reload gets. + */ + const announceWork = async ( + channelId: string, + threadId: string | null, + busy?: boolean, + ): Promise => { + const members = await database + .select({ userId: channelMemberships.userId }) + .from(channelMemberships) + .where(eq(channelMemberships.channelId, channelId)); + if (members.length === 0) return; + + const work = threadId + ? ((await workIn(database, [{ id: channelId, threadId }])).get( + channelId, + ) ?? []) + : []; + const event: ChannelActivityEvent = { + channelId, + memberIds: members.map((member) => member.userId), + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + ...(busy === undefined ? {} : { busy }), + work: work.map(channelWorkDto), + }; + await database.execute( + sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`, + ); + }; + const store: ChannelStore = { create(actor, agentIds) { return database.transaction( @@ -839,31 +889,25 @@ export function createChannelStore( .where(eq(intelligenceChannelMappings.threadId, threadId)) .limit(1); if (!mapped) return; + await announceWork(mapped.channelId, threadId, busy); + }, - const members = await database - .select({ userId: channelMemberships.userId }) - .from(channelMemberships) - .where(eq(channelMemberships.channelId, mapped.channelId)); - if (members.length === 0) return; - - // No table write: busy is a moment, and the roster query stays the source of truth. Just the - // announcement, carrying the members the same way recordActivity does. - const event: ChannelActivityEvent = { - channelId: mapped.channelId, - memberIds: members.map((member) => member.userId), - lastMessage: null, - lastMessageAt: null, - lastMessageAgentId: null, - busy, - }; - await database.execute( - sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`, - ); + async signalWork(threadId) { + const [mapped] = await database + .select({ channelId: intelligenceChannelMappings.channelId }) + .from(intelligenceChannelMappings) + .where(eq(intelligenceChannelMappings.threadId, threadId)) + .limit(1); + if (!mapped) return; + await announceWork(mapped.channelId, threadId); }, async signalChannelBusy(actor, channelId, busy) { const [membership] = await database - .select({ userId: channelMemberships.userId }) + .select({ + userId: channelMemberships.userId, + threadId: intelligenceChannelMappings.threadId, + }) .from(channelMemberships) .innerJoin( channels, @@ -872,33 +916,32 @@ export function createChannelStore( isNull(channels.deletedAt), ), ) + // The caller's own thread for the channel, which is the one their hops are offered from. + .leftJoin( + intelligenceChannelMappings, + and( + eq( + intelligenceChannelMappings.channelId, + channelMemberships.channelId, + ), + eq(intelligenceChannelMappings.userId, channelMemberships.userId), + ), + ) .where( and( eq(channelMemberships.channelId, channelId), eq(channelMemberships.userId, actor.id), ), - ); + ) + .limit(1); // Not a member, no such channel, or a deleted one: the same refusal every way, so belonging // to a channel is not something an outsider can probe for. if (!membership) throw new ChannelNotFoundError(channelId); - const members = await database - .select({ userId: channelMemberships.userId }) - .from(channelMemberships) - .where(eq(channelMemberships.channelId, channelId)); - - const event: ChannelActivityEvent = { - channelId, - memberIds: members.map((member) => member.userId), - lastMessage: null, - lastMessageAt: null, - lastMessageAgentId: null, - busy, - }; - await database.execute( - sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`, - ); + await announceWork(channelId, membership.threadId, busy); }, + + workIn: (list) => workIn(database, list), }; return store; } @@ -1127,8 +1170,16 @@ export function createChannelRoutes( ...(parsed.limit !== undefined ? { limit: parsed.limit } : {}), }); + /* + * What each row is waiting on, read with the page rather than left to the socket. A busy + * flag that only ever arrived as an event was gone on every reload, so a conversation a Bot + * was still answering in looked finished to anybody who refreshed. + */ + const work = await store.workIn(page.channels); return context.json({ - channels: page.channels.map(channelSummaryDto), + channels: page.channels.map((channel) => + channelSummaryDto(channel, work.get(channel.id) ?? []), + ), nextCursor: page.nextCursor, }); } catch (error) { @@ -1231,7 +1282,13 @@ export function createChannelRoutes( if (!channel) { return context.json({ error: "Couldn't find that conversation." }, 404); } - return context.json({ channel: channelDto(channel) }); + const work = await store.workIn([channel]); + return context.json({ + channel: { + ...channelDto(channel), + work: (work.get(channel.id) ?? []).map(channelWorkDto), + }, + }); } catch (error) { return mapStoreError(context, error); } @@ -1257,7 +1314,10 @@ function channelDto(channel: AgentChannel): ChannelWire { }; } -function channelSummaryDto(channel: ChannelSummary) { +function channelSummaryDto( + channel: ChannelSummary, + work: readonly ChannelWork[], +) { return { ...channelDto(channel), summary: channel.summary, @@ -1267,6 +1327,12 @@ function channelSummaryDto(channel: ChannelSummary) { pinned: channel.pinned, // Serialised as ISO-8601 like lastMessageAt, so the browser can compare the two as strings. lastReadAt: channel.lastReadAt?.toISOString() ?? null, + work: work.map(channelWorkDto), + /* + * Only ever true from here. A person's own run is not on any row this can read, so a roster + * that said false would put out the dot another tab's run had just lit. + */ + ...(work.length > 0 ? { busy: true } : {}), }; } diff --git a/server/src/channels/working.ts b/server/src/channels/working.ts new file mode 100644 index 0000000000..60b5b48182 --- /dev/null +++ b/server/src/channels/working.ts @@ -0,0 +1,226 @@ +/** + * What is working on a conversation right now, when nobody's browser is running it. + * + * A conversation can be waiting on work its person cannot see: a Bot it asked, answering in a + * scratch thread nobody is shown, or a routine firing into it. The busy signal used to say only + * "something is running", on the socket and nowhere else, so an open conversation looked idle and a + * reload forgot even that much. + * + * READ FROM THE ROWS THAT ALREADY SAY SO, never written here. A hop is a `work_items` row until it + * is finished and a routine firing is a `routine_runs` row until it has a status, so the queue and + * the run history are the record. A second table of "busy reasons" would be a copy of them that a + * crashed pod leaves behind: the hop would be retried and finished, and the copy would still say the + * conversation was waiting. Derived, the answer a reload gets and the answer a socket event carries + * are the same query. + * + * ONE ENTRY PER PIECE OF WORK, keyed by the work. A hop retried five times is one row on the queue + * and one entry here. A hop whose answer is on its way back is the same entry too: the relay is + * keyed `relay:`, so it is folded into the hop it carries the answer for, and the timer a + * person is watching does not restart when the answer turns around. + */ +import { TURN_LIMITS } from "@openbot/toolkit/limits"; +import { + and, + eq, + gt, + inArray, + isNull, + lt, + lte, + notLike, + or, + sql, +} from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; +import { HANDOFF_KIND } from "../agents/handoff"; +import type { Database } from "../db/client"; +import { agents, routineRuns, routines, workItems } from "../db/schema"; +import { DEFAULT_MAX_ATTEMPTS } from "../work/queue"; + +/** Something a conversation is waiting on. */ +export type ChannelWork = + | { + /** A Bot this conversation's Bot asked, which has not answered here yet. */ + kind: "handoff"; + /** The hop's own key, so two asks of the same Bot are two entries. */ + key: string; + /** The Bot that was asked. */ + botId: string; + /** Its name when the hop was offered. Null on a hop written before names were carried. */ + botName: string | null; + /** When it was asked. */ + since: Date; + } + | { + /** A routine firing into this conversation. */ + kind: "routine"; + /** The run's id. */ + key: string; + routineId: string; + /** The Bot the routine runs as. */ + botId: string; + botName: string | null; + since: Date; + }; + +/** The same, on the wire. */ +export type ChannelWorkWire = Omit & { since: string }; + +export function channelWorkDto(work: ChannelWork): ChannelWorkWire { + return { ...work, since: work.since.toISOString() }; +} + +/** + * How long a routine run with no outcome still counts as running. + * + * The routine sweep's own cutoff for an abandoned run, so this never says a routine is running for + * longer than the sweep would leave its row open. See `ABANDONED_RUN_MS` in routines/sweep.ts. + */ +const ROUTINE_IN_FLIGHT_MS = 2 * TURN_LIMITS.turnTimeoutMs; + +const RELAY_PREFIX = "relay:"; + +type HandoffPayload = { + threadId?: unknown; + toBotId?: unknown; + toName?: unknown; + fromBotId?: unknown; + answerIn?: unknown; +}; + +const text = (value: unknown): string | null => + typeof value === "string" && value.length > 0 ? value : null; + +/** + * What each of these conversations is waiting on, oldest first. A conversation waiting on nothing + * is absent from the map. + */ +export async function workIn( + database: Database, + channels: readonly { id: string; threadId: string }[], +): Promise> { + const found = new Map(); + if (channels.length === 0) return found; + const channelByThread = new Map( + channels.map((channel) => [channel.threadId, channel.id]), + ); + const add = (channelId: string, work: ChannelWork) => { + const list = found.get(channelId) ?? []; + list.push(work); + found.set(channelId, list); + }; + + /* + * The hop that asked, joined to a relay so the relay can be read as the hop it answers: who was + * asked and when. The relay's own payload names the Bot that answered as its sender and nothing + * of when the question was put. + */ + const asked = alias(workItems, "asked"); + const threadOf = sql`${workItems.payload}->>'threadId'`; + const hops = await database + .select({ + key: workItems.key, + payload: workItems.payload, + createdAt: workItems.createdAt, + askedPayload: asked.payload, + askedAt: asked.createdAt, + }) + .from(workItems) + .leftJoin( + asked, + and( + eq(asked.kind, workItems.kind), + sql`${workItems.key} = ${RELAY_PREFIX} || ${asked.key}`, + ), + ) + .where( + and( + eq(workItems.kind, HANDOFF_KIND), + isNull(workItems.finishedAt), + inArray(threadOf, [...channelByThread.keys()]), + // A notice says a hop failed for good. Nothing is coming, so nothing is waited on. + notLike(workItems.key, "notice:%"), + // Given up on: still a row, never claimed again. Unless its last attempt is still running. + or( + lt(workItems.attempts, DEFAULT_MAX_ATTEMPTS), + gt(workItems.leaseUntil, sql`now()`), + ), + /* + * A relay that is running is the asking Bot speaking in this conversation, which is the + * answer arriving rather than a wait for it. Only a relay still queued is a wait. + */ + or( + sql`${workItems.payload}->>'answerIn' is null`, + isNull(workItems.claimedBy), + isNull(workItems.leaseUntil), + lte(workItems.leaseUntil, sql`now()`), + ), + ), + ); + + const seen = new Set(); + for (const hop of hops.sort( + (left, right) => left.createdAt.getTime() - right.createdAt.getTime(), + )) { + const payload = hop.payload as HandoffPayload; + const channelId = channelByThread.get(String(payload.threadId)); + if (!channelId) continue; + const isRelay = text(payload.answerIn) !== null; + const original = (hop.askedPayload ?? null) as HandoffPayload | null; + const key = isRelay ? hop.key.slice(RELAY_PREFIX.length) : hop.key; + if (seen.has(key)) continue; + seen.add(key); + // A relay is sent BY the Bot that was asked, so its sender is the one being waited on. + const botId = isRelay + ? (text(original?.toBotId) ?? text(payload.fromBotId)) + : text(payload.toBotId); + if (!botId) continue; + add(channelId, { + kind: "handoff", + key, + botId, + botName: text(isRelay ? original?.toName : payload.toName), + since: (isRelay ? hop.askedAt : null) ?? hop.createdAt, + }); + } + + const runs = await database + .select({ + runId: routineRuns.id, + routineId: routines.id, + channelId: routines.channelId, + botId: routines.agentId, + botName: agents.name, + startedAt: routineRuns.startedAt, + }) + .from(routineRuns) + .innerJoin(routines, eq(routines.id, routineRuns.routineId)) + .leftJoin(agents, eq(agents.id, routines.agentId)) + .where( + and( + isNull(routineRuns.status), + inArray( + routines.channelId, + channels.map((channel) => channel.id), + ), + gt( + routineRuns.startedAt, + sql`now() - make_interval(secs => ${ROUTINE_IN_FLIGHT_MS / 1000})`, + ), + ), + ); + for (const run of runs) { + add(run.channelId, { + kind: "routine", + key: run.runId, + routineId: run.routineId, + botId: run.botId, + botName: run.botName ?? null, + since: run.startedAt, + }); + } + + for (const list of found.values()) + list.sort((left, right) => left.since.getTime() - right.since.getTime()); + return found; +} diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts index ffee18aee7..18bbd9cc85 100644 --- a/server/tests/channel-routes.test.ts +++ b/server/tests/channel-routes.test.ts @@ -86,6 +86,10 @@ function fakeStore( async softDelete(receivedActor, id) { calls.push(["softDelete", receivedActor, id]); }, + // A read beside the one the route was asked for, so it is not a call worth asserting on. + async workIn() { + return new Map(); + }, }; return Object.assign(base, overrides, { calls }); @@ -158,6 +162,81 @@ describe("channel input parser", () => { }); }); +/* + * What a conversation is waiting on comes back with the read, so a reload shows it. It used to live + * on the socket and nowhere else, and a refresh in the middle of a hop forgot it. + */ +describe("what a channel is waiting on", () => { + const since = new Date("2026-10-26T09:00:00.000Z"); + const waiting = new Map([ + [ + "channel-1", + [ + { + kind: "handoff" as const, + key: "hop-1", + botId: "ana", + botName: "Ana", + since, + }, + ], + ], + ]); + + test("the channel read carries the list", async () => { + const store = fakeStore({ workIn: async () => waiting }); + + const fetched = await appFor(store).request( + "http://openbot.test/channel-1", + ); + + expect(await json(fetched)).toEqual({ + channel: { + ...channel(), + work: [ + { + kind: "handoff", + key: "hop-1", + botId: "ana", + botName: "Ana", + since: since.toISOString(), + }, + ], + }, + }); + }); + + test("the roster carries it too, and lights only the rows that have some", async () => { + const summary = (id: string) => ({ + ...channel({ id }), + summary: null, + lastMessage: null, + lastMessageAgentId: null, + createdAt: since, + pinned: false, + lastReadAt: null, + }); + const store = fakeStore({ + list: async () => ({ + channels: [summary("channel-1"), summary("channel-2")], + nextCursor: null, + }), + workIn: async () => waiting, + }); + + const response = await appFor(store).request("http://openbot.test/"); + const body = (await response.json()) as { + channels: Array<{ id: string; busy?: boolean; work: unknown[] }>; + }; + + expect(body.channels[0]).toMatchObject({ id: "channel-1", busy: true }); + expect(body.channels[0]?.work).toHaveLength(1); + // Absent rather than false: a person's own run in another tab is not on any row this reads. + expect(body.channels[1]?.busy).toBeUndefined(); + expect(body.channels[1]?.work).toEqual([]); + }); +}); + describe("channel list limit", () => { /** * `?limit=` used to be read with `Number.parseInt`, which coerces: `"12abc"` arrived as 12 @@ -282,7 +361,9 @@ describe("channel routes", () => { }, }); expect(fetched.status).toBe(200); - expect(await json(fetched)).toEqual({ channel: channel() }); + expect(await json(fetched)).toEqual({ + channel: { ...channel(), work: [] }, + }); }); /** @@ -307,7 +388,11 @@ describe("channel routes", () => { expect(fetched.status).toBe(200); expect(await json(fetched)).toEqual({ - channel: { ...channel(), lastMessageAt: spokenAt.toISOString() }, + channel: { + ...channel(), + lastMessageAt: spokenAt.toISOString(), + work: [], + }, }); }); diff --git a/server/tests/channel-work.integration.test.ts b/server/tests/channel-work.integration.test.ts new file mode 100644 index 0000000000..babc3db3b9 --- /dev/null +++ b/server/tests/channel-work.integration.test.ts @@ -0,0 +1,353 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, like, sql } from "drizzle-orm"; +import { HANDOFF_KIND } from "../src/agents/handoff"; +import { createAgentProfileStore } from "../src/agents/profile-store"; +import type { AgentActor } from "../src/agents/profile-types"; +import { + type ChannelActivityEvent, + createChannelEventHub, + startChannelActivityListener, +} from "../src/channels/events"; +import { createChannelStore } from "../src/channels/routes"; +import { createThreadIdentity } from "../src/channels/thread-identity"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + channels, + intelligenceChannelMappings, + routineRuns, + routines, + users, + workItems, +} from "../src/db/schema"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/** + * What a conversation is waiting on, read from the rows that already say so. + * + * A Bot it asked is a `work_items` row until the hop is finished; a routine firing into it is a + * `routine_runs` row until it has an outcome. These tests write those rows the way the queue and + * the routine sweep do and read the answer back, because the list is a query over them and the + * only way to know the query is right is to run it. + */ + +const databaseUrl = testDatabaseUrl(); +const database = createDatabase(databaseUrl, TEST_POOL); +const profileStore = createAgentProfileStore( + database, + new URL("https://managed.example.test/ag-ui"), +); +const store = createChannelStore( + database, + profileStore, + createThreadIdentity("test-deployment"), +); + +const testPrefix = `channel-work-${randomUUID()}`; +const createdUserIds: string[] = []; +const createdAgentIds: string[] = []; +const createdChannelIds: string[] = []; +const createdRoutineIds: string[] = []; + +afterEach(async () => { + await database + .delete(workItems) + .where(like(workItems.key, `%${testPrefix}%`)); + for (const routineId of createdRoutineIds.splice(0)) { + await database.delete(routines).where(eq(routines.id, routineId)); + } + for (const channelId of createdChannelIds.splice(0)) { + await database + .delete(intelligenceChannelMappings) + .where(eq(intelligenceChannelMappings.channelId, channelId)); + await database.delete(channels).where(eq(channels.id, channelId)); + } + for (const agentId of createdAgentIds.splice(0)) { + await database + .delete(agentProfiles) + .where(eq(agentProfiles.agentId, agentId)); + await database.delete(agents).where(eq(agents.id, agentId)); + } + for (const userId of createdUserIds.splice(0)) { + await database.delete(users).where(eq(users.id, userId)); + } +}); + +afterAll(async () => { + await database.$client.close(); +}); + +async function createUser(): Promise { + const id = `${testPrefix}-user-${randomUUID()}`; + await database.insert(users).values({ + id, + email: `${id}@example.test`, + name: "Channel Work Test User", + }); + createdUserIds.push(id); + return { id, role: "user" }; +} + +async function createAgent(owner: AgentActor, name: string) { + const profile = await profileStore.create(owner, { + name, + title: "Research", + roleDescription: "Find things out.", + visibility: "private", + }); + createdAgentIds.push(profile.id); + return profile.id; +} + +async function setup() { + const owner = await createUser(); + const asker = await createAgent(owner, "Assistant"); + const channel = await store.create(owner, [asker]); + createdChannelIds.push(channel.id); + return { owner, asker, channel }; +} + +/** A hop, as `handoff.ts` offers it and the queue then moves it. */ +async function hop(input: { + threadId: string; + toBotId?: string; + toName?: string; + key?: string; + answerIn?: string; + fromBotId?: string; + attempts?: number; + claimed?: boolean; + finished?: boolean; + createdAt?: Date; +}) { + const key = input.key ?? `hop:${testPrefix}:${randomUUID()}`; + await database.insert(workItems).values({ + kind: HANDOFF_KIND, + key, + payload: { + fromBotId: input.fromBotId ?? "assistant", + toBotId: input.toBotId ?? "ana", + ...(input.toName ? { toName: input.toName } : {}), + actorId: "someone", + threadId: input.threadId, + runId: "run-1", + depth: 1, + task: "find the outage window", + ...(input.answerIn ? { answerIn: input.answerIn } : {}), + }, + attempts: input.attempts ?? 0, + ...(input.claimed + ? { + claimedBy: "replica-a", + leaseUntil: sql`now() + interval '1 minute'`, + } + : {}), + ...(input.finished ? { finishedAt: new Date() } : {}), + ...(input.createdAt ? { createdAt: input.createdAt } : {}), + }); + return key; +} + +async function workFor(channel: { id: string; threadId: string }) { + return (await store.workIn([channel])).get(channel.id) ?? []; +} + +describe("what a conversation is waiting on", () => { + test("a Bot it asked, from the moment the hop is queued", async () => { + const { channel } = await setup(); + const key = await hop({ threadId: channel.threadId, toName: "Ana" }); + + const work = await workFor(channel); + + expect(work).toHaveLength(1); + expect(work[0]).toMatchObject({ + kind: "handoff", + key, + botId: "ana", + botName: "Ana", + }); + expect(work[0]?.since).toBeInstanceOf(Date); + }); + + test("nothing, once the hop is finished", async () => { + const { channel } = await setup(); + await hop({ threadId: channel.threadId, finished: true }); + + expect(await workFor(channel)).toEqual([]); + }); + + test("one entry for a hop however many times it was tried", async () => { + const { channel } = await setup(); + await hop({ threadId: channel.threadId, attempts: 3 }); + + expect(await workFor(channel)).toHaveLength(1); + }); + + test("two Bots asked at once are two entries, oldest first", async () => { + const { channel } = await setup(); + await hop({ + threadId: channel.threadId, + toBotId: "ben", + createdAt: new Date(Date.now() - 1_000), + }); + await hop({ + threadId: channel.threadId, + toBotId: "ana", + createdAt: new Date(Date.now() - 5_000), + }); + + const work = await workFor(channel); + + expect(work.map((entry) => entry.botId)).toEqual(["ana", "ben"]); + }); + + test("not a hop that was given up on, nor the notice saying so", async () => { + const { channel } = await setup(); + const key = await hop({ threadId: channel.threadId, attempts: 5 }); + await hop({ + threadId: channel.threadId, + key: `notice:${key}`, + answerIn: channel.threadId, + }); + + expect(await workFor(channel)).toEqual([]); + }); + + test("a given-up hop's last attempt, while it is still running", async () => { + const { channel } = await setup(); + await hop({ threadId: channel.threadId, attempts: 5, claimed: true }); + + expect(await workFor(channel)).toHaveLength(1); + }); + + test("an answer on its way back is the same wait, with the same clock", async () => { + const { channel } = await setup(); + const askedAt = new Date(Date.now() - 30_000); + const key = await hop({ + threadId: channel.threadId, + toName: "Ana", + finished: true, + createdAt: askedAt, + }); + await hop({ + threadId: channel.threadId, + key: `relay:${key}`, + fromBotId: "ana", + toBotId: "assistant", + answerIn: channel.threadId, + }); + + const work = await workFor(channel); + + expect(work).toHaveLength(1); + expect(work[0]).toMatchObject({ + kind: "handoff", + key, + botId: "ana", + botName: "Ana", + }); + expect(work[0]?.since.getTime()).toBe(askedAt.getTime()); + }); + + test("not once the answer is being given", async () => { + const { channel } = await setup(); + const key = await hop({ threadId: channel.threadId, finished: true }); + await hop({ + threadId: channel.threadId, + key: `relay:${key}`, + answerIn: channel.threadId, + claimed: true, + }); + + expect(await workFor(channel)).toEqual([]); + }); + + test("not a hop asked from a thread no conversation shows", async () => { + const { channel } = await setup(); + await hop({ threadId: `scratch-${testPrefix}` }); + + expect(await workFor(channel)).toEqual([]); + }); + + test("a routine firing into it, until the run has an outcome", async () => { + const { owner, asker, channel } = await setup(); + const routineId = `${testPrefix}-routine`; + await database.insert(routines).values({ + id: routineId, + ownerUserId: owner.id, + agentId: asker, + channelId: channel.id, + instruction: "Post the standup summary.", + cron: "0 9 * * *", + nextRunAt: new Date(Date.now() + 86_400_000), + }); + createdRoutineIds.push(routineId); + const runId = `${testPrefix}-run`; + await database.insert(routineRuns).values({ id: runId, routineId }); + + const running = await workFor(channel); + expect(running).toHaveLength(1); + expect(running[0]).toMatchObject({ + kind: "routine", + key: runId, + routineId, + botId: asker, + }); + + await database + .update(routineRuns) + .set({ status: "succeeded", finishedAt: new Date() }) + .where(eq(routineRuns.id, runId)); + expect(await workFor(channel)).toEqual([]); + }); +}); + +/* + * Every busy event carries the whole list, read at the moment it is sent, so the newest event is + * the answer a reload would get. Heard through the real NOTIFY path, because that is the path that + * has a size limit and a serialiser. + */ +describe("announcing it", () => { + test("a busy signal carries the list, and the settle after the hop clears it", async () => { + const { owner, channel } = await setup(); + const heard: ChannelActivityEvent[] = []; + const hub = createChannelEventHub(); + const detach = hub.register(owner.id, (payload) => { + heard.push(JSON.parse(payload) as ChannelActivityEvent); + }); + const listener = await startChannelActivityListener(databaseUrl, hub); + try { + const key = await hop({ threadId: channel.threadId, toName: "Ana" }); + await store.signalBusy(channel.threadId, true); + await database + .update(workItems) + .set({ finishedAt: new Date() }) + .where(eq(workItems.key, key)); + await store.signalWork(channel.threadId); + + const deadline = Date.now() + 5_000; + while (heard.length < 2 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + expect(heard).toHaveLength(2); + expect(heard[0]).toMatchObject({ channelId: channel.id, busy: true }); + expect(heard[0]?.work).toEqual([ + expect.objectContaining({ + kind: "handoff", + key, + botName: "Ana", + since: expect.any(String), + }), + ]); + // A settle says what is left without claiming a turn started or ended. + expect(heard[1]).toMatchObject({ channelId: channel.id, work: [] }); + expect(heard[1]?.busy).toBeUndefined(); + } finally { + detach(); + await listener.stop(); + } + }); +}); From 1608651c3ad52037a11f3375a7074ef13fadcb21 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:26:09 -0300 Subject: [PATCH 18/73] Tell the asking conversation when a hop is queued and when the queue moves The delivery's busy signal fires inside the run, while the row still says the hop is in flight, so it cannot be what says the hop is over. The runner now settles after finish or release, with the relay already queued, and the desk announces a hop the moment it is offered. --- server/src/agents/handoff-runner.ts | 21 +++++++++ server/src/agents/handoff-wiring.ts | 2 + server/src/agents/handoff.ts | 15 +++++- server/src/index.ts | 2 + server/tests/agent-handoff-runner.test.ts | 57 +++++++++++++++++++++++ server/tests/agent-handoff.test.ts | 43 +++++++++++++++++ 6 files changed, 139 insertions(+), 1 deletion(-) diff --git a/server/src/agents/handoff-runner.ts b/server/src/agents/handoff-runner.ts index 2227bdd862..44add93283 100644 --- a/server/src/agents/handoff-runner.ts +++ b/server/src/agents/handoff-runner.ts @@ -128,6 +128,13 @@ export function createHandoffRunner(options: { * which is a matter of one duration outrunning another and does not care about the scale. */ renewEveryMs?: number; + /** + * The queue moved for a hop: finished, released for another try, or dropped. Told after the row + * changed, with the relay already queued, so the conversation that asked can be told what it is + * waiting on now. The delivery's own busy signal fires inside the run, while the row still says + * the hop is in flight, so it cannot be the one that says it is over. Best-effort: never thrown. + */ + onSettled?: (work: HandoffWork) => Promise; }) { const { queue, @@ -139,8 +146,18 @@ export function createHandoffRunner(options: { limit = 5, maxAttempts = DEFAULT_MAX_ATTEMPTS, renewEveryMs = RENEW_EVERY_MS, + onSettled, } = options; + const settled = async (work: HandoffWork) => { + try { + await onSettled?.(work); + } catch { + // A conversation that has not heard is corrected by its next event or its next read; a hop + // reported as failed for it would be run again. + } + }; + /** * Put the failure in front of the person, by running the Bot that asked in the conversation they * are watching. @@ -301,6 +318,7 @@ export function createHandoffRunner(options: { await queue.finish({ kind: HANDOFF_KIND, key: item.key, owner }); ours.delete(item.key); report.skipped.push({ key: item.key, reason: BOT_PAUSED_REASON }); + await settled(work); continue; } @@ -425,6 +443,8 @@ export function createHandoffRunner(options: { ); }); } + // After the relay is queued, so the conversation reads as still waiting on the answer. + await settled(work); await recordAuditEvent(auditStore, { eventType: "agent.handoff_delivered", targetType: "agent", @@ -490,6 +510,7 @@ export function createHandoffRunner(options: { }); ours.delete(item.key); report.skipped.push({ key: item.key, reason }); + await settled(work); await recordAuditEvent(auditStore, { eventType: "agent.handoff_failed", targetType: "agent", diff --git a/server/src/agents/handoff-wiring.ts b/server/src/agents/handoff-wiring.ts index bce0c3e254..b5e3b13d7f 100644 --- a/server/src/agents/handoff-wiring.ts +++ b/server/src/agents/handoff-wiring.ts @@ -60,6 +60,8 @@ export async function startHandoffDelivery(deps: { */ sign: (work, claim) => signHandoffDeliveryRun(work, config.keyEncryptionKey, undefined, claim), + // The asking conversation, told what it is waiting on once the queue has moved for a hop. + onSettled: (work) => channelStore.signalWork(work.threadId), delivery: createHandoffDelivery({ admission: serverTurnAdmission, /* diff --git a/server/src/agents/handoff.ts b/server/src/agents/handoff.ts index ea0a3878fc..babdcdc36b 100644 --- a/server/src/agents/handoff.ts +++ b/server/src/agents/handoff.ts @@ -95,8 +95,15 @@ export function createHandoffDesk(options: { actorFor: (userId: string) => Promise; auditStore: AuditStore; caps: HandoffCaps; + /** + * A hop was queued from this thread. Lets the conversation that asked say what it is waiting on + * before the hop is picked up, which on a busy replica can be minutes. Not awaited: the Bot that + * asked is mid-turn, and a conversation told late is worth less than a tool call kept waiting. + */ + onOffered?: (threadId: string) => Promise; }): HandoffDesk { - const { queue, profiles, mayAddress, actorFor, auditStore, caps } = options; + const { queue, profiles, mayAddress, actorFor, auditStore, caps, onOffered } = + options; /** Said once, so the trail carries the same words the Bot was given. */ async function refuse( @@ -417,6 +424,12 @@ export function createHandoffDesk(options: { }, }); + try { + void onOffered?.(from.threadId).catch(() => {}); + } catch { + // Told on the next event instead. The hop is queued either way. + } + return { ok: true, to: found.id, toName: found.name }; }, }; diff --git a/server/src/index.ts b/server/src/index.ts index 77faeca575..254c6ce953 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1098,6 +1098,8 @@ const handoffDesk = createHandoffDesk({ actorFor(userId).catch(() => null), auditStore: bootAuditStore, caps: config.handoff, + // The asking conversation, told it is now waiting on the Bot it asked. See `onOffered`. + onOffered: (threadId) => channelStore.signalWork(threadId), }); void recordAuditEvent(bootAuditStore, { diff --git a/server/tests/agent-handoff-runner.test.ts b/server/tests/agent-handoff-runner.test.ts index bbbb08788d..b3bda1f248 100644 --- a/server/tests/agent-handoff-runner.test.ts +++ b/server/tests/agent-handoff-runner.test.ts @@ -38,6 +38,7 @@ function runner(options?: { }) => Promise; /** What the delivery says the Bot answered. Null — nothing worth relaying — unless a test cares. */ answer?: string | null; + onSettled?: () => Promise; }) { const calls: Array<{ verb: string; key: string; owner?: string }> = []; const events: string[] = []; @@ -48,6 +49,8 @@ function runner(options?: { const delivered: Array<{ message: string; assertion: string }> = []; const offered: HandoffWork[] = []; const delays: number[] = []; + /** Each time the asking conversation was told the queue moved, with what the queue had seen by then. */ + const settled: Array<{ threadId: string; after: string[] }> = []; const queue = { claim: async () => @@ -101,12 +104,20 @@ function runner(options?: { delivered, offered, delays, + settled, runner: createHandoffRunner({ queue, owner: "replica-a", sign: (work, claim) => signHandoffDeliveryRun(work, KEY, "delivery-run", claim), auditStore, + onSettled: async (work) => { + settled.push({ + threadId: work.threadId, + after: calls.map((call) => call.verb), + }); + await options?.onSettled?.(); + }, delivery: { deliver: async ({ work, message, shown, assertion }) => { delivered.push({ message, assertion }); @@ -654,3 +665,49 @@ describe("what a notice shows", () => { ); }); }); + +/* + * The asking conversation reads what it is waiting on from the queue, so it is told after the queue + * moved and not before. Told from inside the delivery, the row would still say the hop was in + * flight, and "Asking Ana…" would stay on screen for a hop that had finished. + */ +describe("telling the asking conversation", () => { + test("after a delivered hop is finished and its answer is on its way back", async () => { + const { runner: sweep, settled } = runner({ + answer: "Tuesday 02:00 to 04:00.", + }); + + await sweep.sweep(); + + expect(settled).toEqual([ + { threadId: WORK.threadId, after: ["finish", "offer"] }, + ]); + }); + + test("after a failed delivery is released for another try", async () => { + const { runner: sweep, settled } = runner({ + deliver: async () => { + throw new Error("the model stopped"); + }, + }); + + await sweep.sweep(); + + expect(settled).toEqual([{ threadId: WORK.threadId, after: ["release"] }]); + }); + + test("a conversation that cannot be told does not fail the hop", async () => { + const { runner: sweep, calls } = runner({ + onSettled: async () => { + throw new Error("the socket is down"); + }, + }); + + const report = await sweep.sweep(); + + expect(report.delivered).toEqual(["researcher"]); + expect(calls).toEqual([ + { verb: "finish", key: "run-1:abc", owner: "replica-a" }, + ]); + }); +}); diff --git a/server/tests/agent-handoff.test.ts b/server/tests/agent-handoff.test.ts index 97a98e6d95..5d469b315c 100644 --- a/server/tests/agent-handoff.test.ts +++ b/server/tests/agent-handoff.test.ts @@ -47,6 +47,7 @@ function desk(options?: { offered?: number; caps?: HandoffCaps; role?: "admin" | "user"; + onOffered?: (threadId: string) => Promise; }) { const rows: Array<{ kind: string; key: string; payload: unknown }> = []; const events: Array<{ @@ -103,6 +104,7 @@ function desk(options?: { }), auditStore, caps: options?.caps ?? CAPS, + ...(options?.onOffered ? { onOffered: options.onOffered } : {}), }), }; } @@ -137,6 +139,47 @@ describe("handing work to another Bot", () => { }); }); + /* + * The conversation that asked is told at once, so it shows who it is waiting on before the hop is + * picked up. Only for a hop that was queued: a refusal or a repeat starts no wait. + */ + test("a queued hop tells the conversation that asked, and a repeat does not", async () => { + const told: string[] = []; + const { desk: handoff } = desk({ + onOffered: async (threadId) => { + told.push(threadId); + }, + }); + const send = () => + handoff.send({ + from: FROM, + target: "researcher", + envelope: { task: "find the outage window" }, + }); + + await send(); + await send(); + + expect(told).toEqual(["thread-1"]); + }); + + test("a conversation that cannot be told does not refuse the hop", async () => { + const { desk: handoff, rows } = desk({ + onOffered: async () => { + throw new Error("the socket is down"); + }, + }); + + const outcome = await handoff.send({ + from: FROM, + target: "researcher", + envelope: { task: "find the outage window" }, + }); + + expect(outcome).toMatchObject({ ok: true }); + expect(rows).toHaveLength(1); + }); + /* * The key is what stops a retried delivery running the other Bot twice, so the same envelope sent * twice in one run has to land on the same key. A fresh id per attempt is at-least-once with no From a541d6f0f88e4940fbafc2be7d81c4476844ed59 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:26:09 -0300 Subject: [PATCH 19/73] Show a conversation as working while a routine fires into it Signalled around the firing and cleared after the run row has its outcome, since the open run row is what the conversation reads. --- server/src/routines/runner.ts | 131 ++++++++++++++++------------ server/tests/routine-runner.test.ts | 67 ++++++++++++++ server/tests/typecheck-baseline.txt | 1 - 3 files changed, 140 insertions(+), 59 deletions(-) diff --git a/server/src/routines/runner.ts b/server/src/routines/runner.ts index 9c8fd627c5..10333db16f 100644 --- a/server/src/routines/runner.ts +++ b/server/src/routines/runner.ts @@ -140,71 +140,86 @@ export function createRoutineRunner(options: { await routineStore.finishRun(routineRunId, "skipped", BOT_PAUSED_REASON); return; } - let replyText: string; + /* + * The conversation shown as working while the firing runs, and told it is over once the run + * row has its outcome. The run row is what the conversation reads "a routine is running" from + * (see channels/working.ts), so the signal that ends it comes after `finishRun`, not after the + * turn. Best-effort both ways: a firing is not worth failing over a dot. + */ + await channelStore.signalBusy(channel.threadId, true).catch(() => {}); try { - ({ replyText } = await runTurn({ - ownerUserId, - routineId, - agentId, - threadId: channel.threadId, - instruction, - })); - } catch (error) { - if (error instanceof HeadlessToolSuspension) { - await routineStore.finishRun( - routineRunId, - "waiting", - error.message, - error.waiting, - ); - await say(error.message); + await fire(channel.threadId); + } finally { + await channelStore.signalBusy(channel.threadId, false).catch(() => {}); + } + + async function fire(threadId: string): Promise { + let replyText: string; + try { + ({ replyText } = await runTurn({ + ownerUserId, + routineId, + agentId, + threadId, + instruction, + })); + } catch (error) { + if (error instanceof HeadlessToolSuspension) { + await routineStore.finishRun( + routineRunId, + "waiting", + error.message, + error.waiting, + ); + await say(error.message); + return; + } + const reason = reasonOf(error); + await routineStore.finishRun(routineRunId, "failed", reason); + + /* + * THE FATIGUE RULE, read after the failure is recorded — the count has to include this + * firing, or the tenth failure in a row reads as the ninth and the routine keeps going. + * + * Its own try/catch, around the read and the switching-off as well as the message: this all + * happens after `finishRun`, and nothing in it is allowed to throw its way out of a firing + * whose failure is already on the row. + */ + try { + const failures = await routineStore.consecutiveFailures(routineId); + if (failures === 1) { + // Only the first failure after a success. A routine that fails every night at three has + // one line in the channel, not one line a night for a month. + await say(`This routine failed: ${shorten(reason)}`); + } else if (failures >= FATIGUE_LIMIT) { + // A dead integration should not burn model spend for ever. Switched off, and said out + // loud, because a routine that goes quiet without explaining itself is worse than one + // that fails. + await routineStore.setEnabled(ownerUserId, routineId, false); + await say(SWITCHED_OFF); + } + // In between, nothing is said. The run rows carry it, and the routines page reads them. + } catch (fatigueError) { + console.error( + JSON.stringify({ + type: "routine-fatigue-rule-failed", + routineId, + routineRunId, + reason: reasonOf(fatigueError), + }), + ); + } return; } - const reason = reasonOf(error); - await routineStore.finishRun(routineRunId, "failed", reason); /* - * THE FATIGUE RULE, read after the failure is recorded — the count has to include this - * firing, or the tenth failure in a row reads as the ninth and the routine keeps going. - * - * Its own try/catch, around the read and the switching-off as well as the message: this all - * happens after `finishRun`, and nothing in it is allowed to throw its way out of a firing - * whose failure is already on the row. + * The reply, then the outcome. `say` cannot throw, and `finishRun` is finish-once, so neither + * order can lose the run row — this one is the browser's order: what was said lands in the + * channel, and then the firing is closed. */ - try { - const failures = await routineStore.consecutiveFailures(routineId); - if (failures === 1) { - // Only the first failure after a success. A routine that fails every night at three has - // one line in the channel, not one line a night for a month. - await say(`This routine failed: ${shorten(reason)}`); - } else if (failures >= FATIGUE_LIMIT) { - // A dead integration should not burn model spend for ever. Switched off, and said out - // loud, because a routine that goes quiet without explaining itself is worse than one - // that fails. - await routineStore.setEnabled(ownerUserId, routineId, false); - await say(SWITCHED_OFF); - } - // In between, nothing is said. The run rows carry it, and the routines page reads them. - } catch (fatigueError) { - console.error( - JSON.stringify({ - type: "routine-fatigue-rule-failed", - routineId, - routineRunId, - reason: reasonOf(fatigueError), - }), - ); - } - return; + await say(replyText); + await routineStore.finishRun(routineRunId, "succeeded"); } - - /* - * The reply, then the outcome. `say` cannot throw, and `finishRun` is finish-once, so neither - * order can lose the run row — this one is the browser's order: what was said lands in the - * channel, and then the firing is closed. - */ - await say(replyText); - await routineStore.finishRun(routineRunId, "succeeded"); } return { diff --git a/server/tests/routine-runner.test.ts b/server/tests/routine-runner.test.ts index bb12a31408..91cf8bd954 100644 --- a/server/tests/routine-runner.test.ts +++ b/server/tests/routine-runner.test.ts @@ -56,6 +56,8 @@ type Recorded = { }[]; enabled: { ownerUserId: string; id: string; enabled: boolean }[]; turns: Parameters[0][]; + /** Each busy signal, with how many run rows had been closed when it was sent. */ + busy: { threadId: string; busy: boolean; finishedBefore: number }[]; }; function unreachable(method: string): never { @@ -68,12 +70,14 @@ function harness(options: { failures?: number; runTurn?: TurnRunner; recordActivity?: () => Promise; + signalBusy?: () => Promise; }) { const recorded: Recorded = { finished: [], activity: [], enabled: [], turns: [], + busy: [], }; const routineStore: RoutineStore = { @@ -114,6 +118,10 @@ function harness(options: { setPinned: () => unreachable("channels.setPinned"), markRead: () => unreachable("channels.markRead"), softDelete: () => unreachable("channels.softDelete"), + direct: () => unreachable("channels.direct"), + signalChannelBusy: () => unreachable("channels.signalChannelBusy"), + signalWork: () => unreachable("channels.signalWork"), + workIn: () => unreachable("channels.workIn"), async get(actor, channelId) { expect(actor).toEqual(OWNER); @@ -124,6 +132,14 @@ function harness(options: { recorded.activity.push({ actor, channelId, activity }); if (options.recordActivity) await options.recordActivity(); }, + async signalBusy(threadId, busy) { + recorded.busy.push({ + threadId, + busy, + finishedBefore: recorded.finished.length, + }); + if (options.signalBusy) await options.signalBusy(); + }, }; const runTurn: TurnRunner = async (input) => { @@ -257,6 +273,7 @@ describe("createRoutineRunner", () => { activity: [], enabled: [], turns: [], + busy: [], }); }); @@ -338,4 +355,54 @@ describe("createRoutineRunner", () => { "This routine failed: not an Error object", ); }); + /* + * The conversation reads "a routine is running" from the open run row, so the signal that says + * it is over has to come after the row is closed. Sent before, the event would still list the + * routine and the row would stay on screen until something else happened in the conversation. + */ + test("shows the conversation working while it fires, and clears it after the run is closed", async () => { + const { runner, recorded } = harness({}); + + await runner.run(RUN_ID); + + expect(recorded.busy).toEqual([ + { threadId: CHANNEL.threadId, busy: true, finishedBefore: 0 }, + { threadId: CHANNEL.threadId, busy: false, finishedBefore: 1 }, + ]); + }); + + test("clears the working signal when the turn fails", async () => { + const { runner, recorded } = harness({ + runTurn: throwingTurn, + failures: 2, + }); + + await runner.run(RUN_ID); + + expect(recorded.busy.map((signal) => signal.busy)).toEqual([true, false]); + expect(recorded.busy.at(-1)?.finishedBefore).toBe(1); + }); + + test("still runs and records the firing when the working signal cannot be sent", async () => { + const { runner, recorded } = harness({ + signalBusy: async () => { + throw new Error("the socket is down"); + }, + }); + + await runner.run(RUN_ID); + + expect(recorded.turns).toHaveLength(1); + expect(recorded.finished).toEqual([ + { runId: RUN_ID, status: "succeeded", error: undefined }, + ]); + }); + + test("signals nothing for a firing that never runs", async () => { + const { runner, recorded } = harness({ channel: null }); + + await runner.run(RUN_ID); + + expect(recorded.busy).toEqual([]); + }); }); diff --git a/server/tests/typecheck-baseline.txt b/server/tests/typecheck-baseline.txt index ce21d9f4e6..6b8a092993 100644 --- a/server/tests/typecheck-baseline.txt +++ b/server/tests/typecheck-baseline.txt @@ -206,7 +206,6 @@ server/tests/remote-coordination.integration.test.ts: error TS2769: No overload server/tests/responsibility-triggers.test.ts: error TS2353: Object literal may only specify known properties, and 'agentId' does not exist in type 'ResolvedTrigger'. server/tests/routine-routes.test.ts: error TS2739: Type '{ create(): Promise; listFor(ownerUserId: string): Promise; update(): Promise; remove(ownerUserId: string, id: string): Promise<...>; ... 9 more ...; consecutiveFailures(): Promise<...>; }' is missing the following properties from type 'RoutineStore': startManualRun, listRuns, reapAbandonedRuns, markUnschedulable server/tests/routine-run-now.test.ts: error TS2322: Type '() => Promise<({ id: string; status: null; startedAt: Date; finishedAt: null; error: null; } | { id: string; status: "failed"; startedAt: Date; finishedAt: Date; error: string; })[]>' is not assignable to type '(ownerUserId: string, id: string, limit?: number | undefined) => Promise'. -server/tests/routine-runner.test.ts: error TS2739: Type '{ create: () => never; list: () => never; setPinned: () => never; markRead: () => never; softDelete: () => never; get(actor: AgentActor, channelId: string): Promise; recordActivity(actor: AgentActor, channelId: string, activity: ChannelActivity): Promise<...>; }' is missing the following properties from type 'ChannelStore': direct, signalBusy, signalChannelBusy server/tests/routine-runner.test.ts: error TS2740: Type '{ create: () => never; listFor: () => never; update: () => never; remove: () => never; dueRoutines: () => never; advanceNextRun: () => never; insertRun: () => never; runContext(runId: string): Promise; finishRun(runId: string, status: RoutineRunOutcome, error: string | undefined): Promise<....' is missing the following properties from type 'RoutineStore': startManualRun, listRuns, routineForFiring, reapAbandonedRuns, and 3 more. server/tests/routine-runner.test.ts: error TS2741: Property 'lastMessageAt' is missing in type '{ id: string; name: string; agentIds: string[]; threadId: string; active: true; }' but required in type 'AgentChannel'. server/tests/routing-routes.test.ts: error TS2345: Argument of type '(input: { id: string; name: string; roleDescription: string; visibility: AgentVisibility; }) => AgentProfile' is not assignable to parameter of type '(value: { id: string; name: string; roleDescription: string; visibility: string; }, index: number, array: { id: string; name: string; roleDescription: string; visibility: string; }[]) => AgentProfile'. From fef0aa008bf3458cc5d790578e8c054421496657 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:26:22 -0300 Subject: [PATCH 20/73] Show a computer call turned away by a cold start as not done yet, and tell the Bot to try again --- app/src/components/channels/tool-line.tsx | 11 ++++++- app/src/lib/computers/call.ts | 17 +++++++++++ app/src/lib/copilot/computer-tools.tsx | 36 +++++++++++++++++++++-- app/tests/computer-calm-start.test.tsx | 15 ++++++++++ app/tests/computer-tool-refusals.test.ts | 35 ++++++++++++++++++++++ 5 files changed, 111 insertions(+), 3 deletions(-) diff --git a/app/src/components/channels/tool-line.tsx b/app/src/components/channels/tool-line.tsx index 91306c720f..528d1d794e 100644 --- a/app/src/components/channels/tool-line.tsx +++ b/app/src/components/channels/tool-line.tsx @@ -17,6 +17,7 @@ export function ToolLine({ running, refused, failed, + notYet, children, }: { /** Drawn before the label, small and muted. A group of steps puts its kinds here. */ @@ -30,6 +31,12 @@ export function ToolLine({ refused?: boolean; /** It was permitted and did not work. A different request might. */ failed?: boolean; + /** + * Nothing was done yet, for an ordinary reason: a computer still starting turned the call away + * before it reached anything. Muted like a success, because nothing went wrong, and labelled so it + * does not read as one. + */ + notYet?: boolean; /** Shown when the line is expanded. Without it the line is not expandable. */ children?: ReactNode; }) { @@ -51,7 +58,9 @@ export function ToolLine({ ? `Blocked: ${label}` : failed ? `${label}, didn't work` - : label} + : notYet + ? `${label}, not done yet` + : label} {detail ? {detail} : null} diff --git a/app/src/lib/computers/call.ts b/app/src/lib/computers/call.ts index 38357e5696..52a333ec41 100644 --- a/app/src/lib/computers/call.ts +++ b/app/src/lib/computers/call.ts @@ -94,6 +94,23 @@ export function failedComputerCall( status: number, body: Record | null, ): ToolOutcome { + /* + * A cold start that outlasted the server's wait. Said as what it is: the computer is starting, this + * call did nothing, and asking again shortly is the right next step. Kept apart from the failures + * below so the line in the transcript reads as a wait, and so the model is not handed something + * that looks like a broken computer and gives up on one seconds from ready. + */ + if (body?.starting === true) { + return { + ok: false, + starting: true, + notPerformed: true, + reason: + typeof body.error === "string" + ? body.error + : "The computer is still starting. This call was not performed. Try it again in a moment.", + }; + } return { ok: false, reason: typeof body?.error === "string" ? body.error : "That did not work.", diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index 2e6d2ccc64..aace024639 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -61,6 +61,8 @@ type ComputerOutcome = { refused?: boolean; reason?: string; staleRefs?: boolean; + /** The computer was still starting and the call was not performed. */ + starting?: boolean; elements?: unknown[]; element?: { role?: string; name?: string }; /** What a shell call reports back, so the line can show the output rather than only the command. */ @@ -160,6 +162,7 @@ function ActionLine({ running, refused, failed, + notYet, }: { label: string; detail?: string; @@ -168,21 +171,42 @@ function ActionLine({ refused?: boolean; /** It was permitted and did not work. A different request might. */ failed?: boolean; + /** The computer was still starting, so nothing was done yet. See `stillStarting`. */ + notYet?: boolean; }) { return ( ); } +/** What a line says about a call that waited on a computer still starting. */ +const STILL_STARTING_DETAIL = "The computer is still starting."; + /** Whether a result is an ordinary failure rather than a refusal, so the two can render differently. */ function didNotWork(outcome: ComputerOutcome): boolean { - return outcome.ok === false && outcome.refused !== true; + return ( + outcome.ok === false && + outcome.refused !== true && + outcome.starting !== true + ); +} + +/** + * Whether a call came back because the computer was still starting, and so did nothing. + * + * Not a failure, and not drawn as one. A cold start can outlast the server's wait, and when it does + * the call is turned away before anything reaches the computer. Drawn as "didn't work" in amber, it + * read as a broken computer during the one minute every first start takes. + */ +function stillStarting(outcome: ComputerOutcome): boolean { + return outcome.ok === false && outcome.starting === true; } export function ComputerTools() { @@ -362,6 +386,7 @@ export function ComputerTools() { } refused={outcomeOf(result).refused === true} failed={didNotWork(outcomeOf(result))} + notYet={stillStarting(outcomeOf(result))} /> ), }); @@ -408,6 +433,7 @@ export function ComputerTools() { } refused={outcome.refused === true} failed={didNotWork(outcome)} + notYet={stillStarting(outcome)} /> ); }, @@ -451,6 +477,7 @@ export function ComputerTools() { detail={typeof args?.key === "string" ? args.key : undefined} refused={outcomeOf(result).refused === true} failed={didNotWork(outcomeOf(result))} + notYet={stillStarting(outcomeOf(result))} /> ), }); @@ -625,6 +652,7 @@ export function ComputerTools() { } refused={outcome.refused === true} failed={didNotWork(outcome)} + notYet={stillStarting(outcome)} /> ); }, @@ -671,6 +699,7 @@ export function ComputerTools() { } refused={outcome.refused === true} failed={didNotWork(outcome)} + notYet={stillStarting(outcome)} /> ); }, @@ -748,6 +777,7 @@ export function ComputerTools() { } refused={outcome.refused === true} failed={didNotWork(outcome) || exit !== 0} + notYet={stillStarting(outcome)} > {status === "complete" ? ( ); }, @@ -860,6 +891,7 @@ export function ComputerTools() { label="Scrolled" refused={outcomeOf(result).refused === true} failed={didNotWork(outcomeOf(result))} + notYet={stillStarting(outcomeOf(result))} /> ), }); diff --git a/app/tests/computer-calm-start.test.tsx b/app/tests/computer-calm-start.test.tsx index 84f8c48cbf..83dcd74fcc 100644 --- a/app/tests/computer-calm-start.test.tsx +++ b/app/tests/computer-calm-start.test.tsx @@ -9,8 +9,23 @@ import { waitFor, } from "@testing-library/react"; import type { ReactElement } from "react"; +import { ToolLine } from "@/components/channels/tool-line"; import { ComputerView } from "@/components/computer/computer-view"; +test("a call turned away by a computer still starting reads as not done yet, not as a failure", () => { + const view = render( + , + ); + const line = view.getByText("Clicked, not done yet"); + expect(line.closest(".text-amber-600")).toBeNull(); + expect(line.closest(".text-destructive")).toBeNull(); + expect(view.queryByText(/didn't work/)).toBeNull(); +}); + /** * A computer starting in front of a person never reads as a failure. * diff --git a/app/tests/computer-tool-refusals.test.ts b/app/tests/computer-tool-refusals.test.ts index 8404f7fb68..6f291f83f5 100644 --- a/app/tests/computer-tool-refusals.test.ts +++ b/app/tests/computer-tool-refusals.test.ts @@ -25,6 +25,41 @@ function serverAnswering(status: number, body: unknown) { })) as unknown as typeof fetch; } +describe("a computer still starting", () => { + /* + * A cold start that outlasted the server's wait. The model has to be told the call did nothing and + * is worth repeating; read as an ordinary failure it apologised and gave up on a computer that was + * seconds from ready. + */ + test("is reported as starting and not performed, in the server's own words", async () => { + serverAnswering(503, { + error: + "The computer is still starting. This call was not performed. Try it again in a moment.", + starting: true, + notPerformed: true, + }); + + const outcome = await callComputer("bot-1", "/click", { method: "POST" }); + + expect(outcome).toEqual({ + ok: false, + starting: true, + notPerformed: true, + reason: + "The computer is still starting. This call was not performed. Try it again in a moment.", + }); + }); + + test("an ordinary unavailable computer is not mistaken for one starting", async () => { + serverAnswering(503, { error: "The computer did not respond." }); + + const outcome = await callComputer("bot-1", "/click", { method: "POST" }); + + expect(outcome.starting).toBeUndefined(); + expect(outcome.reason).toBe("The computer did not respond."); + }); +}); + describe("a computer call the server refused", () => { test("a person holding the wheel is reported as that, not as stale refs", async () => { serverAnswering(409, { From 1d39656f9f45646f856110d13fbc0e732e7b8363 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:26:45 -0300 Subject: [PATCH 21/73] Have a Bot say what it is about to do before its first computer action --- shared/bot-prompt.test.ts | 7 +++++++ shared/bot-prompt.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/shared/bot-prompt.test.ts b/shared/bot-prompt.test.ts index 2f5d14f34d..453332936e 100644 --- a/shared/bot-prompt.test.ts +++ b/shared/bot-prompt.test.ts @@ -28,4 +28,11 @@ describe("COMPUTER_GUIDANCE", () => { expect(COMPUTER_GUIDANCE).toContain("Opening a page is not a web search."); expect(COMPUTER_GUIDANCE).toContain("cannot search"); }); + + test("says what it is about to do before its first computer action", () => { + // A first action can sit through a cold start; a sentence first means the wait has a reason. + expect(COMPUTER_GUIDANCE).toContain( + "Before your first computer action in a turn, say in one sentence what you are about to do and where.", + ); + }); }); diff --git a/shared/bot-prompt.ts b/shared/bot-prompt.ts index 306e97566b..abd8feeb46 100644 --- a/shared/bot-prompt.ts +++ b/shared/bot-prompt.ts @@ -65,6 +65,7 @@ const COMPUTER_GUIDANCE_LINES = [ "Some actions are refused by this deployment's policy. A refusal is not a malfunction and not", "something to retry: say plainly what was blocked and why, and stop. Do not try another route to", "the same thing.", + "Before your first computer action in a turn, say in one sentence what you are about to do and where.", "Say what you found or did in plain language, briefly.", ]; From dcfbd0be602fdce71fceb285b885f3e57b14d74b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:27:22 -0300 Subject: [PATCH 22/73] Tell a Bot's owner when an app is theirs to give it, and let an admin decide which apps are --- app/src/lib/enterprise/queries.ts | 3 +- server/src/admin/capabilities.ts | 7 + server/src/audit.ts | 8 + server/src/plugins/app-access-tool.ts | 5 + server/src/plugins/app-access.ts | 67 +++++- server/src/plugins/owner-grants.ts | 59 +++++ server/src/plugins/plugin-store/app-access.ts | 213 ++++++++++++++++-- server/src/plugins/plugin-store/grants.ts | 66 +++++- server/src/plugins/plugin-store/model.ts | 7 + server/src/plugins/plugin-store/servers.ts | 13 ++ .../app-access-routes.integration.test.ts | 28 ++- server/tests/app-access-state.test.ts | 59 +++++ server/tests/app-access-tool.test.ts | 10 +- server/tests/plugin-routes.test.ts | 2 + 14 files changed, 512 insertions(+), 35 deletions(-) create mode 100644 server/src/plugins/owner-grants.ts diff --git a/app/src/lib/enterprise/queries.ts b/app/src/lib/enterprise/queries.ts index 7e58b53313..4078cee654 100644 --- a/app/src/lib/enterprise/queries.ts +++ b/app/src/lib/enterprise/queries.ts @@ -10,7 +10,8 @@ export type CapabilityKey = | "customRules" | "passwordManager" | "slackTeams" - | "teamBots"; + | "teamBots" + | "ownerApps"; /** One switch as the server stored it at each scope. `null` means no row: the scope inherits. */ export type CapabilitySetting = { diff --git a/server/src/admin/capabilities.ts b/server/src/admin/capabilities.ts index 8205d15dce..3af70aff9b 100644 --- a/server/src/admin/capabilities.ts +++ b/server/src/admin/capabilities.ts @@ -25,6 +25,7 @@ export const CAPABILITIES = [ "passwordManager", "slackTeams", "teamBots", + "ownerApps", ] as const; export type Capability = (typeof CAPABILITIES)[number]; @@ -80,6 +81,11 @@ export const CAPABILITY_LABELS: Record< title: "Team Bots", description: "People may publish a Bot for the whole team to use.", }, + ownerApps: { + title: "Give apps to their own Bots", + description: + "People may let Bots they own use apps they connected themselves.", + }, }; /** @@ -97,6 +103,7 @@ export const DEFAULT_CAPABILITIES: Record = { passwordManager: true, slackTeams: true, teamBots: true, + ownerApps: true, }; export type CapabilityRow = { diff --git a/server/src/audit.ts b/server/src/audit.ts index 441c2f5902..44939b23be 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -280,6 +280,14 @@ export const auditEventTypes = [ "app_access.approved", "app_access.dismissed", "app_access.refused", + /* + * A Bot's owner giving one of their own apps to their own Bot, or taking it back, without an + * administrator (`plugins/owner-grants.ts`). Target the app key; payload `app`, `bot`, and the + * refs given or taken. Each ref also has its own `plugin_granted` / `plugin_revoked` row marked + * `grantedAs: owner`. + */ + "app_access.owner_granted", + "app_access.owner_revoked", // Every action a Bot takes on its computer, allowed or refused. Both, always: a trail that records // only what was permitted cannot answer whether the Bot tried. "computer.action_allowed", diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts index 3c9a60fdf9..6f06b8b666 100644 --- a/server/src/plugins/app-access-tool.ts +++ b/server/src/plugins/app-access-tool.ts @@ -5,6 +5,7 @@ import { import type { GrantedTool } from "@openbot/toolkit/granted-tool"; import { z } from "zod"; import type { AppAccess, AppAccessState } from "./app-access"; +import { ownerAppsAllowed } from "./owner-grants"; import type { AppDirectory, SharedAudienceCheck, @@ -74,6 +75,9 @@ export function appAccessTools(options: { ...(options.sharedAudience ? { sharedAudience: options.sharedAudience } : {}), + ownerGrantsAllowed: options.actorId + ? await ownerAppsAllowed(options.actorId) + : false, }); /* * No card for an app nobody can name. A card about it would have nothing to ask the server @@ -116,6 +120,7 @@ function toldTheModel(access: AppAccess): string { shared_not_approved: `${name} uses one shared account for this deployment, and you reach more people than an administrator approved for it, so its calls are refused. Only an administrator can approve that; the card in front of the person says so.`, disabled: `${name} is switched off on this deployment. The card in front of the person lets them ask an administrator to switch it on.`, not_granted: `${name} is switched on, and you have not been granted it. The card in front of the person lets them ask an administrator to grant it to you.`, + owner_grantable: `${name} is switched on, and you have not been given it. This person owns you and can give it to you themselves: the card in front of them asks whether to let you use their ${name}. Ask them to try again once they have.`, }; return `${said[access.state]} Do not browse to its website or ask them to sign in there.`; } diff --git a/server/src/plugins/app-access.ts b/server/src/plugins/app-access.ts index 749fe5a826..276eb659b0 100644 --- a/server/src/plugins/app-access.ts +++ b/server/src/plugins/app-access.ts @@ -25,7 +25,7 @@ import type { CatalogueEntry } from "./catalogue"; */ /** - * The seven things a person can be told about one app on one Bot. + * The eight things a person can be told about one app on one Bot. * * `ready` — usable now, as far as this deployment knows. * `not_connected` — enabled and held, and this app is used under each person's own account, which @@ -40,6 +40,9 @@ import type { CatalogueEntry } from "./catalogue"; * `disabled` — switched off at the deployment: no row, the broker not configured, or an app that * still needs something only an administrator can supply. * `not_granted` — enabled, and this Bot holds none of its tools. Only an administrator can grant. + * `owner_grantable` — enabled, this Bot holds none of its tools, and the person looking owns the Bot + * and may give it the app themselves, because the app's rule ({@link ownerGrantsOf}) lets owners + * decide. Theirs to do, so nothing is filed. */ export const APP_ACCESS_STATES = [ "ready", @@ -49,6 +52,7 @@ export const APP_ACCESS_STATES = [ "shared_not_approved", "disabled", "not_granted", + "owner_grantable", ] as const; export type AppAccessState = (typeof APP_ACCESS_STATES)[number]; @@ -74,8 +78,15 @@ export const REQUEST_KIND_FOR: Record< shared_not_approved: null, disabled: "enable", not_granted: "grant", + owner_grantable: null, }; +/** + * Who may give an app to a Bot: the Bot's owner, for their own Bot (`any`), or only an + * administrator (`admin`). See `mcp_servers.owner_grants`. + */ +export type OwnerGrants = "any" | "admin"; + /** Whose account the app's calls land in. */ export type AppAccount = /** Each person's own, which each person connects. */ @@ -136,6 +147,19 @@ export type AppAccess = { asksFirst: boolean; /** Whether this Bot holds any of the app's tools. */ granted: boolean; + /** Who may give the app to a Bot, with the default for its account filled in. */ + ownerGrants: OwnerGrants; + /** + * Whether every grant this Bot holds on the app was made by the Bot's owner, and so may be taken + * back by them too. False where the Bot holds nothing, or where an administrator gave any of it. + */ + givenByOwner: boolean; + /** + * Whether giving the app to the Bot (`owner_grantable`) has to start with connecting the person's + * own account, because the app runs on it and they have none, or one the vendor refused. False in + * every other state. + */ + needsConnect: boolean; /** * When the account this app runs in for this person was connected — their own, or the team's for * a Shared app — and null where none is, or where no account is involved. @@ -174,6 +198,14 @@ export type AppAccessFacts = { /** Whether the row holds a credential: the OAuth client, or the deployment's token. */ hasCredential: boolean; granted: boolean; + /** The app's `owner_grants` column, null where an administrator never chose. */ + ownerGrants: OwnerGrants | null; + /** + * Whether the person asking may give their own apps to this Bot: they own it (a Bot with no owner + * — a package's public Bot — is nobody's to give to), and no administrator has switched the + * feature off for them. Whether THIS app may be given is {@link ownerGrantsOf}'s answer, not this. + */ + mayGiveOwn: boolean; /** This person's own connection, or null where they have none. */ personal: { usable: boolean } | null; /** The deployment's account at a Shared app, or null where none is connected. */ @@ -185,8 +217,14 @@ export type AppAccessFacts = { sharedAdmitted: boolean | null; }; +/** The facts that say whose account an app runs on, and so what its owner rule defaults to. */ +type AccountFacts = Pick< + AppAccessFacts, + "provider" | "authScheme" | "accountMode" | "catalogueAuth" +>; + /** Whose account, from the same facts `accessFor` resolves a call with. */ -export function accountOf(facts: AppAccessFacts): AppAccount { +export function accountOf(facts: AccountFacts): AppAccount { if (brokeredProvenance(facts.provider)) { if (schemeKind(facts.authScheme) === "none") return "deployment"; return facts.accountMode === "shared" ? "shared" : "personal"; @@ -198,6 +236,26 @@ export function accountOf(facts: AppAccessFacts): AppAccount { return "deployment"; } +/** + * Who may give this app to a Bot: what an administrator chose, or the default for its account. + * + * OWNERS DECIDE BY DEFAULT ONLY WHERE THE APP RUNS ON THE ASKER'S OWN ACCOUNT. A Bot given somebody's + * Gmail by its owner reads, for anybody else who talks to it, that person's own Gmail and nothing + * more — so letting the owner decide hands nobody anything they did not already have. The built-in + * Routines have no account at all and run as whoever scheduled them, the same shape, and "every + * morning…" has to work on a Bot made five minutes ago. Everything else reaches one account that is + * not the asker's — the deployment's token, a Shared team account, a server that needs no credential + * and so answers everybody alike — and giving a Bot that is an administrator's decision until an + * administrator says otherwise. + */ +export function ownerGrantsOf( + facts: AccountFacts & { ownerGrants: OwnerGrants | null }, +): OwnerGrants { + if (facts.ownerGrants) return facts.ownerGrants; + if (facts.catalogueAuth?.kind === "builtin") return "any"; + return accountOf(facts) === "personal" ? "any" : "admin"; +} + export function connectOf(facts: AppAccessFacts): AppConnect { if (brokeredProvenance(facts.provider)) { const kind = schemeKind(facts.authScheme); @@ -256,7 +314,10 @@ export function unavailableReason(facts: AppAccessFacts): Unavailable | null { */ export function appAccessState(facts: AppAccessFacts): AppAccessState { if (unavailableReason(facts) !== null) return "disabled"; - if (!facts.granted) return "not_granted"; + if (!facts.granted) + return facts.mayGiveOwn && ownerGrantsOf(facts) === "any" + ? "owner_grantable" + : "not_granted"; const account = accountOf(facts); if (account === "deployment") return "ready"; if (account === "shared") { diff --git a/server/src/plugins/owner-grants.ts b/server/src/plugins/owner-grants.ts new file mode 100644 index 0000000000..4852f4ac38 --- /dev/null +++ b/server/src/plugins/owner-grants.ts @@ -0,0 +1,59 @@ +import { capabilityRefusal } from "../admin/capabilities"; +import { enterpriseControls } from "../admin/controls"; +import type { AppInspection } from "./plugin-store/app-access"; +import type { PluginStore } from "./store"; + +/** + * A Bot's owner giving one of their own apps to their own Bot, without an administrator. + * + * WHY THIS IS NOT THE ADMINISTRATOR'S GRANT WITH THE CHECK TAKEN OFF. Three things differ, and each + * is the reason the other two are safe: + * + * - WHICH APPS. Only one an administrator has added, switched on and opened to owners + * (`mcp_servers.owner_grants`, read through `ownerGrantsOf`). By default that is an app every + * person connects for themselves, and the built-in Routines. An owner never adds or switches on an + * app; that stays the administrator's. + * - WHICH BOTS. Only one the person owns. A Bot nobody owns — a package's public Bot — reaches + * everybody on behalf of nobody, and stays an administrator's to equip. + * - WHOSE ACCOUNT. The grant is marked `granted_as = owner`, and a call made under it always uses + * the asking person's own account, even on the owner's published Team Bot. See + * `credentialActorFor`. Publishing a Bot must not hand teammates the owner's mailbox. + * + * And an administrator can switch the whole thing off for anybody through the `ownerApps` + * capability, which every check here reads. + */ + +/** Whether an administrator left this person able to give their own apps to their own Bots. */ +export async function ownerAppsAllowed(userId: string): Promise { + return ( + (await enterpriseControls()?.capabilityFor(userId, "ownerApps")) ?? true + ); +} + +/** + * Why this person may not give this app to this Bot themselves, or null when they may. The sentence + * is the one they read. + * + * ASKED OF THE INSPECTION, NOT RE-DERIVED. Whether the app is switched on and who it is open to are + * the same answers the card and the Bot page drew; the Bot's owner is read here again because a 403 + * is decided on what is true now, not on what the page loaded a minute ago. + */ +export async function ownerGrantRefusal( + store: Pick, + actorId: string, + agentId: string, + inspected: AppInspection, +): Promise { + const name = inspected.access.displayName; + if ( + !(await store.agentIsRegistered(agentId)) || + (await store.agentOwner(agentId)) !== actorId + ) + return "You can only give apps to Bots you own."; + if (!(await ownerAppsAllowed(actorId))) return capabilityRefusal("ownerApps"); + if (inspected.unavailable !== null || !inspected.answeringId) + return `${name} isn't turned on yet. Only an admin can turn it on.`; + if (inspected.access.ownerGrants !== "any") + return `Only an admin can give ${name} to a Bot.`; + return null; +} diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts index 3ab22b6005..dc7a9435fd 100644 --- a/server/src/plugins/plugin-store/app-access.ts +++ b/server/src/plugins/plugin-store/app-access.ts @@ -35,12 +35,19 @@ import { accountOf, appAccessState, connectOf, + type OwnerGrants, + ownerGrantsOf, REQUEST_KIND_FOR, type Unavailable, unavailableReason, } from "../app-access"; import type { BrokerApp } from "../broker"; -import { CATALOGUE, type CatalogueEntry, catalogueEntry } from "../catalogue"; +import { + CATALOGUE, + type CatalogueEntry, + catalogueEntry, + classifyTool, +} from "../catalogue"; import type { StoreContext } from "./context"; import { iso } from "./model"; @@ -66,6 +73,8 @@ type AppCandidate = { authScheme: string | null; accountMode: "personal" | "shared" | null; hasCredential: boolean; + /** The answering row's `owner_grants`, null where nobody chose or there is no row. */ + ownerGrants: OwnerGrants | null; /** * Whether anything but the caller's own word says this app exists: a row, a catalogue entry, or * the vendor's directory. False only for an id in the broker's namespace (`composio-`, @@ -84,8 +93,15 @@ type ServerRow = { authScheme: string | null; accountMode: "personal" | "shared" | null; credentialId: string | null; + ownerGrants: OwnerGrants | null; }; +/** An app a person could give their own Bots. See `giveableApps`. */ +export type GiveableApp = Pick< + AppAccess, + "key" | "displayName" | "logo" | "account" +>; + /** The directory, asked only to name an app this deployment has no row for. */ export type AppDirectory = () => Promise; @@ -96,6 +112,8 @@ export type AppInspection = { unavailable: Unavailable | null; /** The row a grant is written against, and null for an app with no row. */ answeringId: string | null; + /** Every row that is this app, which is where the grants an owner takes back may be. */ + rowIds: string[]; /** The app at the broker, for a brokered one. */ toolkit: string | null; /** The catalogue entry, for an app this deployment ships. */ @@ -189,6 +207,7 @@ export function appAccessMethods(context: StoreContext) { authScheme: mcpServers.authScheme, accountMode: mcpServers.accountMode, credentialId: mcpServers.credentialId, + ownerGrants: mcpServers.ownerGrants, }) .from(mcpServers) .orderBy(asc(mcpServers.id)); @@ -240,6 +259,7 @@ export function appAccessMethods(context: StoreContext) { authScheme: toolkit ? (head?.authScheme ?? null) : source.authScheme, accountMode: toolkit ? (head?.accountMode ?? null) : null, hasCredential: source.credentialId !== null, + ownerGrants: source.ownerGrants, vouched: true, }; apps.set(key, app); @@ -279,6 +299,7 @@ export function appAccessMethods(context: StoreContext) { authScheme: null, accountMode: null, hasCredential: false, + ownerGrants: null, vouched: input.vouched ?? true, }; } @@ -405,17 +426,49 @@ export function appAccessMethods(context: StoreContext) { return absentFor(isBrokeredId(lower) ? lower : asked, undefined, listed); } - /** The server halves of a Bot's MCP grants. */ - async function grantedServerIds(agentId: string): Promise> { + /** + * The server halves of a Bot's MCP grants, each with whether any of its grants an administrator + * made. A server whose every grant is the owner's reads `false`: see `plugin_grants.granted_as`. + */ + async function grantsByServer( + agentId: string, + ): Promise> { const grants = await database - .select({ ref: pluginGrants.ref }) + .select({ ref: pluginGrants.ref, grantedAs: pluginGrants.grantedAs }) .from(pluginGrants) .where( and(eq(pluginGrants.kind, "mcp"), eq(pluginGrants.agentId, agentId)), ); - return new Set( - grants.map((grant) => grant.ref.split("/")[0] ?? "").filter(Boolean), - ); + const servers = new Map(); + for (const grant of grants) { + const serverId = grant.ref.split("/")[0] ?? ""; + if (!serverId) continue; + const byAdmin = grant.grantedAs !== "owner"; + servers.set(serverId, { + byAdmin: byAdmin || (servers.get(serverId)?.byAdmin ?? false), + }); + } + return servers; + } + + /** The server halves of a Bot's MCP grants. */ + async function grantedServerIds(agentId: string): Promise> { + return new Set((await grantsByServer(agentId)).keys()); + } + + /** Who owns a Bot that still stands: a person's id, or null for one nobody owns or none at all. */ + async function botOwnerOf(agentId: string): Promise { + const [row] = await database + .select({ ownerUserId: agentProfiles.ownerUserId }) + .from(agentProfiles) + .where( + and( + eq(agentProfiles.agentId, agentId), + isNull(agentProfiles.deletedAt), + ), + ) + .limit(1); + return row?.ownerUserId ?? null; } /** @@ -605,6 +658,13 @@ export function appAccessMethods(context: StoreContext) { * otherwise on the person's own, after they allow it. So the owner's connection decides the state * there, and a teammate is never offered a Connect that changes nothing, nor told `ready` over a * call that will stop to ask them first without saying so. + * + * EXCEPT WHERE THE OWNER GAVE THE APP THEMSELVES. A grant the Bot's owner made always runs on the + * asking person's own account (see `plugin_grants.granted_as`), so where every grant this Bot holds + * on the app is the owner's, the owner's connection is not asked about and the person's own is. + * + * `ownerGrantsAllowed` is whether an administrator left this person able to give their own apps + * to their own Bots at all — the `ownerApps` capability — and true where nobody asked. */ async function describe( apps: AppCandidate[], @@ -613,13 +673,18 @@ export function appAccessMethods(context: StoreContext) { agentId: string; composioConfigured: boolean; sharedAudience?: SharedAudienceCheck; + ownerGrantsAllowed?: boolean; }, ): Promise { const { userId } = input; const owner = userId ? await teamBotOwnerFor(input.agentId, userId) : null; - const [granted, connections, ownerConnections, consented, pending] = + const mayGiveOwn = + userId !== null && + input.ownerGrantsAllowed !== false && + (await botOwnerOf(input.agentId)) === userId; + const [grants, connections, ownerConnections, consented, pending] = await Promise.all([ - grantedServerIds(input.agentId), + grantsByServer(input.agentId), connectionFacts(apps, userId), owner ? connectionFacts(apps, owner) : null, owner && userId @@ -647,14 +712,21 @@ export function appAccessMethods(context: StoreContext) { return Promise.all( apps.map(async (app) => { const ids = [app.key, ...app.rowIds]; + const held = ids.flatMap((id) => { + const grant = grants.get(id); + return grant ? [grant] : []; + }); + const givenByOwner = + held.length > 0 && held.every((grant) => !grant.byAdmin); const teamAccount = app.toolkit ? connections.deploymentBrokered.get(app.toolkit) : undefined; const sharedUsable = teamAccount?.usable === true; const own = personalIn(connections, app); - const owners = ownerConnections - ? personalIn(ownerConnections, app) - : undefined; + const owners = + ownerConnections && !givenByOwner + ? personalIn(ownerConnections, app) + : undefined; const personal = owners ?? own; const known: AppAccessFacts = { provider: app.provider, @@ -664,7 +736,9 @@ export function appAccessMethods(context: StoreContext) { accountMode: app.accountMode, catalogueAuth: app.entry?.auth ?? null, hasCredential: app.hasCredential, - granted: ids.some((id) => granted.has(id)), + granted: held.length > 0, + ownerGrants: app.ownerGrants, + mayGiveOwn, personal: personal === undefined ? null : { usable: personal.usable }, shared: teamAccount ? { usable: sharedUsable } : null, sharedAdmitted: null, @@ -722,6 +796,12 @@ export function appAccessMethods(context: StoreContext) { owner !== null && !ids.some((id) => consented.has(id)), granted: facts.granted, + ownerGrants: ownerGrantsOf(facts), + givenByOwner, + needsConnect: + state === "owner_grantable" && + account === "personal" && + !facts.personal?.usable, connectedAt: account === "shared" ? (teamAccount?.connectedAt ?? null) @@ -739,6 +819,7 @@ export function appAccessMethods(context: StoreContext) { }, unavailable: unavailableReason(facts), answeringId: app.answeringId, + rowIds: app.rowIds, toolkit: app.toolkit, entry: app.entry, sharedUsable, @@ -836,6 +917,7 @@ export function appAccessMethods(context: StoreContext) { composioConfigured: boolean; directory?: AppDirectory; sharedAudience?: SharedAudienceCheck; + ownerGrantsAllowed?: boolean; }): Promise { const granted = await grantedServerIds(input.agentId); if (granted.size === 0) return []; @@ -878,6 +960,7 @@ export function appAccessMethods(context: StoreContext) { composioConfigured: boolean; directory?: AppDirectory; sharedAudience?: SharedAudienceCheck; + ownerGrantsAllowed?: boolean; }): Promise { const candidate = await candidateFor( input.app, @@ -889,11 +972,104 @@ export function appAccessMethods(context: StoreContext) { return described ?? null; }, + /** + * The apps this person could give their own Bots, as the New Bot sheet and a Bot's page offer + * them: switched on and usable, opened to owners ({@link ownerGrantsOf}), and — for an app that + * runs on each person's own account — connected by this person with a key nobody refused. + * + * NOT ABOUT ANY ONE BOT. Whether a given Bot is theirs to give to is asked where it is given; this + * is the list of what they hold. Sorted by name. + */ + async giveableApps(input: { + userId: string; + composioConfigured: boolean; + }): Promise { + const apps = [...new Set((await appsByRow(await allRows())).values())]; + const connections = await connectionFacts(apps, input.userId); + const giveable: GiveableApp[] = []; + for (const app of apps) { + const own = + brokeredProvenance(app.provider) && app.toolkit + ? connections.personalBrokered.get(app.toolkit) + : app.provider === "catalogue" + ? app.rowIds + .map((id) => connections.personalHeld.get(id)) + .find((value) => value !== undefined) + : undefined; + const facts: AppAccessFacts = { + provider: app.provider, + present: app.present, + composioConfigured: input.composioConfigured, + authScheme: app.authScheme, + accountMode: app.accountMode, + catalogueAuth: app.entry?.auth ?? null, + hasCredential: app.hasCredential, + granted: false, + ownerGrants: app.ownerGrants, + mayGiveOwn: true, + personal: own ? { usable: own.usable } : null, + shared: null, + sharedAdmitted: null, + }; + if (unavailableReason(facts) !== null) continue; + if (ownerGrantsOf(facts) !== "any") continue; + const account = accountOf(facts); + if (account === "personal" && !own?.usable) continue; + giveable.push({ + key: app.key, + displayName: app.title, + logo: app.logo, + account, + }); + } + return giveable.sort((left, right) => + left.displayName.localeCompare(right.displayName), + ); + }, + + /** + * Who may give an app to a Bot, as an administrator chose it, or null to put the default for its + * account back. Written on every row at the app's url, so a duplicate row answers the same, and + * recorded in the trail. Answers false for an app with no row. + */ + async setOwnerGrants(input: { + serverId: string; + ownerGrants: OwnerGrants | null; + by: string; + }): Promise { + const [row] = await database + .select({ url: mcpServers.url, ownerGrants: mcpServers.ownerGrants }) + .from(mcpServers) + .where(eq(mcpServers.id, input.serverId)) + .limit(1); + if (!row) return false; + await database + .update(mcpServers) + .set({ ownerGrants: input.ownerGrants, updatedAt: new Date() }) + .where(eq(mcpServers.url, row.url)); + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: input.serverId, + payload: { + actor: input.by, + change: "owner_grants_changed", + from: row.ownerGrants, + to: input.ownerGrants, + }, + }); + return true; + }, + /** * Every tool the app's answering row advertises, as grant refs, and whether each is a read. * - * `read` IS THE EXACT WORD AND NOTHING ELSE, as `classifyTool` reads `effect`: a null or an - * unrecognised value is a write, so a tool nobody classified is never handed out as a read. + * A READ IS WHAT `classifyTool` CALLS ONE, the same answer the call gate and the Plugins page + * give. For a brokered or custom app that is the exact word `read` in `effect` and nothing + * else, so a tool nobody classified is never handed out as a read. For an app this deployment + * ships it is also anything advertised that the reviewed entry does not list as a write — which + * this used to miss, reading `effect` alone, so a one-press approval of a built-in app whose + * listing records no effect found no reads at all and refused. */ async appTools( serverId: string, @@ -903,9 +1079,10 @@ export function appAccessMethods(context: StoreContext) { .from(mcpTools) .where(eq(mcpTools.serverId, serverId)) .orderBy(asc(mcpTools.name)); + const entry = catalogueEntry(serverId); return rows.map((row) => ({ ref: `${serverId}/${row.name}`, - read: row.effect === "read", + read: classifyTool(entry, row.name, true, row.effect) === "read", })); }, @@ -1079,7 +1256,9 @@ export function appAccessMethods(context: StoreContext) { | "app_access.cancelled" | "app_access.approved" | "app_access.dismissed" - | "app_access.refused"; + | "app_access.refused" + | "app_access.owner_granted" + | "app_access.owner_revoked"; }, ): Promise { await recordAuditEvent(auditStore, { diff --git a/server/src/plugins/plugin-store/grants.ts b/server/src/plugins/plugin-store/grants.ts index 86e8f6fd39..bb6b285a3a 100644 --- a/server/src/plugins/plugin-store/grants.ts +++ b/server/src/plugins/plugin-store/grants.ts @@ -13,18 +13,30 @@ import { export function grantMethods(context: StoreContext) { const { database, auditStore, toolsDeclaredBy } = context; return { + /** + * Give a Bot one thing, as an administrator or — `as: "owner"` — as the Bot's owner giving it one + * of their own apps. See `plugin_grants.granted_as` for what the difference decides. + * + * AN OWNER'S GRANT NEVER OVERWRITES AN ADMINISTRATOR'S. An administrator's grant of the same + * action already stands for a reason the owner did not give, and turning it into the owner's + * would also change whose account it uses. So the owner's insert does nothing on a conflict. An + * administrator granting over an owner's row makes it theirs: that is an administrator deciding. + */ async grant( kind: PluginKind, ref: string, agentId: string, by: string, + as: "admin" | "owner" = "admin", ): Promise { - await database + const insert = database .insert(pluginGrants) - .values({ kind, ref, agentId, grantedBy: by }) - .onConflictDoUpdate({ + .values({ kind, ref, agentId, grantedBy: by, grantedAs: as }); + if (as === "owner") await insert.onConflictDoNothing(); + else + await insert.onConflictDoUpdate({ target: [pluginGrants.kind, pluginGrants.ref, pluginGrants.agentId], - set: { grantedBy: by, updatedAt: new Date() }, + set: { grantedBy: by, grantedAs: "admin", updatedAt: new Date() }, }); await recordAuditEvent(auditStore, { @@ -37,10 +49,56 @@ export function grantMethods(context: StoreContext) { kind, ref, bot: agentId, + ...(as === "owner" ? { grantedAs: "owner" } : {}), }, }); }, + /** + * Take back what the Bot's owner gave it on one app: every grant on these server rows that the + * owner made, and none an administrator made. Answers the refs taken, each with its own trail + * row, as {@link revoke} writes one. + */ + async revokeOwnerGrants( + serverIds: string[], + agentId: string, + by: string, + ): Promise { + if (serverIds.length === 0) return []; + const owned = and( + eq(pluginGrants.kind, "mcp"), + eq(pluginGrants.agentId, agentId), + eq(pluginGrants.grantedAs, "owner"), + ); + const refs = ( + await database + .select({ ref: pluginGrants.ref }) + .from(pluginGrants) + .where(owned) + ) + .map((row) => row.ref) + .filter((ref) => serverIds.includes(ref.split("/")[0] ?? "")); + if (refs.length === 0) return []; + await database + .delete(pluginGrants) + .where(and(owned, inArray(pluginGrants.ref, refs))); + for (const ref of refs) + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: grantTargetType("mcp"), + targetId: ref, + payload: { + actor: by, + change: "plugin_revoked", + kind: "mcp", + ref, + bot: agentId, + grantedAs: "owner", + }, + }); + return refs; + }, + async revoke( kind: PluginKind, ref: string, diff --git a/server/src/plugins/plugin-store/model.ts b/server/src/plugins/plugin-store/model.ts index b961c4712a..1231729e7d 100644 --- a/server/src/plugins/plugin-store/model.ts +++ b/server/src/plugins/plugin-store/model.ts @@ -159,6 +159,13 @@ export type ServerRecord = { * connected would tell the page something that changes nothing a call does. */ sharedVendorConnected: boolean; + /** + * Who may give this app to a Bot, with the default for its account filled in: any Bot's owner + * (`any`), or only an administrator (`admin`). See `ownerGrantsOf`. + */ + ownerGrants: "any" | "admin"; + /** What an administrator chose, or null where the default above is in force. */ + ownerGrantsChosen: "any" | "admin" | null; tools: ToolRecord[]; /** * Grants on tools this server no longer advertises. diff --git a/server/src/plugins/plugin-store/servers.ts b/server/src/plugins/plugin-store/servers.ts index 521b934ec9..3532ea7094 100644 --- a/server/src/plugins/plugin-store/servers.ts +++ b/server/src/plugins/plugin-store/servers.ts @@ -14,6 +14,7 @@ import { pluginGrants, } from "../../db/schema"; import { BROKERED_PROFILE, brokeredProvenance, toolkitOfFor } from "../access"; +import { ownerGrantsOf } from "../app-access"; import { type BrokerConnection, BrokerUnconfiguredError, @@ -2004,6 +2005,18 @@ export function serverMethods(context: StoreContext) { toolkit !== null && brokeredApps.get(row.url)?.accountMode === "shared" && deploymentHeld.has(toolkit), + ownerGrants: ownerGrantsOf({ + provider: toolkit ? brokerProvider : entry ? "catalogue" : "custom", + authScheme: toolkit + ? (brokeredApps.get(row.url)?.authScheme ?? null) + : row.authScheme, + accountMode: toolkit + ? (brokeredApps.get(row.url)?.accountMode ?? null) + : row.accountMode, + catalogueAuth: entry?.auth ?? null, + ownerGrants: row.ownerGrants, + }), + ownerGrantsChosen: row.ownerGrants, tools: tools .filter((tool) => tool.serverId === row.id) .map((tool) => { diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts index 7870c44db7..feedeaf099 100644 --- a/server/tests/app-access-routes.integration.test.ts +++ b/server/tests/app-access-routes.integration.test.ts @@ -61,6 +61,13 @@ const id = (name: string) => `${name}-${suite}`; const person = id("person"); const other = id("other"); const admin = id("admin"); +/** + * Who owns the suite's Bots: nobody the requests below are filed as. A Bot's owner may give it one + * of their own apps without asking anybody (`owner_grantable`), so a person asking an administrator + * for a grant is, here, asking about somebody else's Bot. `owner-app-grants.integration.test.ts` + * covers the owner's side. + */ +const maker = id("maker"); const bot = id("bot"); const hidden = id("hidden"); @@ -327,7 +334,7 @@ async function clearApps() { } beforeAll(async () => { - for (const user of [person, other, admin]) + for (const user of [person, other, admin, maker]) await database.insert(users).values({ id: user, email: `${user}@example.test`, @@ -343,8 +350,9 @@ beforeAll(async () => { }); await database.insert(agentProfiles).values({ agentId: agent, - ownerUserId: person, - visibility: "private", + ownerUserId: maker, + // Public, so the people asking below are in the audience a Shared app's approval covers. + visibility: agent === bot ? "public" : "private", title: "Helper", roleDescription: "Helper", avatarSeed: "x", @@ -362,7 +370,9 @@ beforeEach(async () => { afterAll(async () => { await clearApps(); await database.delete(agents).where(inArray(agents.id, [bot, hidden])); - await database.delete(users).where(inArray(users.id, [person, other, admin])); + await database + .delete(users) + .where(inArray(users.id, [person, other, admin, maker])); }); describe("what each app a Bot holds is, for the person asking", () => { @@ -1577,7 +1587,7 @@ describe("a teammate on somebody else's Team Bot", () => { beforeEach(async () => { await database .insert(teamBotPublications) - .values({ agentId: bot, publishedBy: person, audience: "team" }); + .values({ agentId: bot, publishedBy: maker, audience: "team" }); await seedApp({ key: GMAIL, title: "Gmail" }); }); const connect = ( @@ -1601,7 +1611,7 @@ describe("a teammate on somebody else's Team Bot", () => { ).app; test("the owner's connection is the one their calls use", async () => { - await connect(person); + await connect(maker); expect(await asTeammate()).toMatchObject({ state: "ready", accountHolder: "owner", @@ -1610,7 +1620,7 @@ describe("a teammate on somebody else's Team Bot", () => { }); test("the owner's refused connection is theirs to reconnect, not the teammate's", async () => { - await connect(person, { verified: false, probeAction: "GMAIL_LIST" }); + await connect(maker, { verified: false, probeAction: "GMAIL_LIST" }); await connect(other); expect(await asTeammate()).toMatchObject({ state: "reconnect", @@ -1640,9 +1650,9 @@ describe("a teammate on somebody else's Team Bot", () => { }); test("the owner reads their own account, and is never asked", async () => { - await connect(person); + await connect(maker); const { app } = await json<{ app: AppAccess }>( - await appAs(person).request(`/api/plugins/for/${bot}/apps/${GMAIL}`), + await appAs(maker).request(`/api/plugins/for/${bot}/apps/${GMAIL}`), ); expect(app).toMatchObject({ state: "ready", diff --git a/server/tests/app-access-state.test.ts b/server/tests/app-access-state.test.ts index e2e4428b7a..06b4c34646 100644 --- a/server/tests/app-access-state.test.ts +++ b/server/tests/app-access-state.test.ts @@ -5,6 +5,7 @@ import { accountOf, appAccessState, connectOf, + ownerGrantsOf, REQUEST_KIND_FOR, unavailableReason, } from "../src/plugins/app-access"; @@ -27,6 +28,8 @@ const composio = (overrides: Partial = {}): AppAccessFacts => ({ catalogueAuth: null, hasCredential: false, granted: true, + ownerGrants: null, + mayGiveOwn: false, personal: null, shared: null, sharedAdmitted: null, @@ -51,6 +54,8 @@ const catalogue = ( catalogueAuth: auth, hasCredential: true, granted: true, + ownerGrants: null, + mayGiveOwn: false, personal: null, shared: null, sharedAdmitted: null, @@ -192,5 +197,59 @@ test("only the three states an administrator can fix file a request", () => { shared_not_approved: null, disabled: "enable", not_granted: "grant", + // The Bot's owner gives it the app themselves; there is nobody to ask. + owner_grantable: null, + }); +}); + +describe("who may give an app to a Bot", () => { + const routines = catalogueEntry("routines"); + if (!routines) throw new Error("The Routines entry is missing."); + + test("owners decide by default where the app runs on the asker's own account, and for Routines", () => { + expect(ownerGrantsOf(composio())).toBe("any"); + expect(ownerGrantsOf(catalogue(drive.auth))).toBe("any"); + expect(ownerGrantsOf(catalogue(routines.auth))).toBe("any"); + }); + + test("an admin decides by default for a team account, a deployment token and no account", () => { + expect(ownerGrantsOf(composio({ accountMode: "shared" }))).toBe("admin"); + expect( + ownerGrantsOf(composio({ authScheme: "NO_AUTH", accountMode: null })), + ).toBe("admin"); + expect(ownerGrantsOf(catalogue(parallel.auth))).toBe("admin"); + expect(ownerGrantsOf({ ...catalogue(null), provider: "custom" })).toBe( + "admin", + ); + }); + + test("what an administrator chose wins over the default, either way", () => { + expect(ownerGrantsOf(composio({ ownerGrants: "admin" }))).toBe("admin"); + expect( + ownerGrantsOf(composio({ accountMode: "shared", ownerGrants: "any" })), + ).toBe("any"); + }); + + test("an app the Bot does not hold is the owner's to give only where both say so", () => { + const notHeld = { granted: false }; + expect(appAccessState(composio({ ...notHeld, mayGiveOwn: true }))).toBe( + "owner_grantable", + ); + // Not their Bot, or the feature switched off for them. + expect(appAccessState(composio({ ...notHeld, mayGiveOwn: false }))).toBe( + "not_granted", + ); + // Their Bot, and an app an administrator keeps for themselves. + expect( + appAccessState( + composio({ ...notHeld, mayGiveOwn: true, ownerGrants: "admin" }), + ), + ).toBe("not_granted"); + // Switched off is still switched off: an owner never turns an app on. + expect( + appAccessState( + composio({ ...notHeld, mayGiveOwn: true, present: false }), + ), + ).toBe("disabled"); }); }); diff --git a/server/tests/app-access-tool.test.ts b/server/tests/app-access-tool.test.ts index d3a31f7a10..38b6c28fc1 100644 --- a/server/tests/app-access-tool.test.ts +++ b/server/tests/app-access-tool.test.ts @@ -32,7 +32,13 @@ function access(state: AppAccessState): AppAccess { unavailable: state === "disabled" ? "absent" : null, accountHolder: "you", asksFirst: false, - granted: state !== "not_granted" && state !== "disabled", + granted: + state !== "not_granted" && + state !== "disabled" && + state !== "owner_grantable", + ownerGrants: "any", + givenByOwner: false, + needsConnect: false, connectedAt: null, requestKind: null, request: null, @@ -72,6 +78,7 @@ describe("show_app_access", () => { agentId: "expenses", userId: "usr_7", composioConfigured: true, + ownerGrantsAllowed: true, }, ]); }); @@ -83,6 +90,7 @@ describe("show_app_access", () => { ["shared_not_connected", /ask an administrator for it/], ["disabled", /switched off on this deployment/], ["not_granted", /you have not been granted it/], + ["owner_grantable", /can give it to you themselves/], ] as const)( "answers %s with the card marker and the state in words", async (state, said) => { diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 94bdb0e23d..c468d29b8f 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -140,6 +140,8 @@ function serverRecord(row: { id: string; url: string }): ServerRecord { authScheme: null, accountMode: null, sharedVendorConnected: false, + ownerGrants: "admin", + ownerGrantsChosen: null, tools: [], withdrawn: [], }; From 919c7f095ce5cb794ce4a8ff560826e3839bc85a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:27:25 -0300 Subject: [PATCH 23/73] Let a Bot's owner give it their own apps, and take back only what they gave --- server/src/plugins/route-groups/app-access.ts | 191 +++++++++++++++++- server/src/plugins/route-groups/grants.ts | 66 +++++- 2 files changed, 252 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts index bb99c6e35c..f957345155 100644 --- a/server/src/plugins/route-groups/app-access.ts +++ b/server/src/plugins/route-groups/app-access.ts @@ -4,9 +4,11 @@ import { BROKERED_PROFILE, type BrokerProvider } from "../access"; import type { AppAccess, AppAccessRequestKind, + OwnerGrants, Unavailable, } from "../app-access"; import type { CatalogueEntry } from "../catalogue"; +import { ownerAppsAllowed, ownerGrantRefusal } from "../owner-grants"; import type { AppAccessRequestRecord, AppDirectory, @@ -368,6 +370,7 @@ export function mountAppAccessRoutes({ composioConfigured, directory, sharedAudience, + ownerGrantsAllowed: await ownerAppsAllowed(context.var.actor.id), }), }); }); @@ -391,6 +394,7 @@ export function mountAppAccessRoutes({ composioConfigured, directory, sharedAudience, + ownerGrantsAllowed: await ownerAppsAllowed(context.var.actor.id), }); if (!inspected) return context.json({ error: `OpenBot has no app called ${app}.` }, 404); @@ -437,6 +441,7 @@ export function mountAppAccessRoutes({ composioConfigured, directory, sharedAudience, + ownerGrantsAllowed: await ownerAppsAllowed(context.var.actor.id), }); const inspected = found && (found.vouched || found.access.granted) ? found : null; @@ -457,7 +462,9 @@ export function mountAppAccessRoutes({ error: access.state === "ready" ? `${access.displayName} is ready to use. There's nothing to ask an admin for.` - : `${access.displayName} needs your own account, not an admin. Connect it yourself.`, + : access.state === "owner_grantable" + ? `This is your Bot, so you can give it ${access.displayName} yourself.` + : `${access.displayName} needs your own account, not an admin. Connect it yourself.`, status: 409, }); return { agentId, access, requestKind: access.requestKind }; @@ -580,6 +587,188 @@ export function mountAppAccessRoutes({ }, ); + /** + * The Bot and the app an owner's give or take-back names, inspected for this person: the Bot one + * they may use, the app one this deployment can name. The refusal otherwise. + */ + const ownersApp = async (context: RouteContext) => { + const agentId = context.req.param("agentId")?.trim() ?? ""; + const app = context.req.param("app")?.trim() ?? ""; + if ( + !agentId || + !app || + agentId.length > MAX_NAME || + app.length > MAX_NAME || + !(await canUseBot(context.var.actor, agentId)) + ) + return context.json({ error: "There is no such Bot." }, 404); + const found = await store.inspectApp({ + app, + agentId, + userId: context.var.actor.id, + composioConfigured, + directory, + sharedAudience, + ownerGrantsAllowed: await ownerAppsAllowed(context.var.actor.id), + }); + if (!found || !(found.vouched || found.access.granted)) + return context.json({ error: `OpenBot has no app called ${app}.` }, 404); + return { agentId, inspected: found }; + }; + + /** + * A Bot's owner giving it one of their own apps, as one press: the card's "Let Research Bot use + * your Gmail?", a switch on the Bot's page, a box left ticked on the New Bot sheet. + * + * WHAT MAY BE GIVEN IS {@link ownerGrantRefusal}'S ANSWER: their own Bot, an app an administrator + * switched on and opened to owners, and the `ownerApps` capability. Refused 403 with the sentence + * otherwise, and the refusal is in the trail. + * + * WHAT IS GIVEN IS WHAT AN ADMINISTRATOR'S ONE PRESS GIVES: the app's read-only actions, through + * the same `appTools` reading, so an owner never gets more by asking themselves than an + * administrator's approval would have given. Anything that sends or writes is still chosen action by + * action by an administrator. ONE EXCEPTION, the built-in Routines: every action it has keeps, + * changes or drops the asking person's own standing instructions and nothing else, and "every + * morning, …" is a write — given only its reads, a Bot could list routines and never make one. + * + * Every grant is `granted_as = owner`, which keeps each call on the asking person's own account + * (`credentialActorFor`). A Bot that already holds any of the app is answered with nothing given. + */ + routes.post("/for/:agentId/apps/:app/give", requireUser, async (context) => { + const found = await ownersApp(context); + if (found instanceof Response) return found; + const { agentId, inspected } = found; + const { access } = inspected; + const deny = (reason: string, error: string, status: 403 | 409) => + refused(context, { + app: access.key, + bot: agentId, + reason, + error, + status, + }); + const refusal = await ownerGrantRefusal( + store, + context.var.actor.id, + agentId, + inspected, + ); + if (refusal) return deny("owner_grant_refused", refusal, 403); + if (access.granted) return context.json({ granted: [] }); + const serverId = inspected.answeringId ?? ""; + const tools = await store.appTools(serverId); + if (tools.length === 0) + return deny( + "no_tools", + `${access.displayName} has nothing to give yet. Ask an admin to refresh it.`, + 409, + ); + const builtin = inspected.entry?.auth.kind === "builtin"; + const refs = tools + .filter((tool) => builtin || tool.read) + .map((tool) => tool.ref); + if (refs.length === 0) + return deny( + "no_read_only_tools", + `${access.displayName} has nothing a Bot can only read with. Ask an admin to choose what this Bot can do with it.`, + 409, + ); + for (const ref of refs) + await store.grant("mcp", ref, agentId, actorEmail(context), "owner"); + await store.recordAppAccessEvent({ + eventType: "app_access.owner_granted", + targetId: access.key, + actorUserId: context.var.actor.id, + payload: { app: access.key, bot: agentId, refs }, + }); + return context.json({ granted: refs }); + }); + + /** + * A Bot's owner taking back what they gave it on one app. Only the owner's own grants go: one an + * administrator made stays, because it was not the owner's to give. Still asks that the Bot is + * theirs; it does not ask whether the app is still open to owners, since taking away is always + * allowed (see `enablementRefusal`'s note on `intent`). + */ + routes.delete( + "/for/:agentId/apps/:app/give", + requireUser, + async (context) => { + const found = await ownersApp(context); + if (found instanceof Response) return found; + const { agentId, inspected } = found; + if ((await store.agentOwner(agentId)) !== context.var.actor.id) + return refused(context, { + app: inspected.access.key, + bot: agentId, + reason: "owner_revoke_refused", + error: "You can only change apps on Bots you own.", + status: 403, + }); + const ids = [ + ...new Set([ + inspected.access.key, + ...inspected.rowIds, + ...(inspected.answeringId ? [inspected.answeringId] : []), + ]), + ]; + const refs = await store.revokeOwnerGrants( + ids, + agentId, + actorEmail(context), + ); + if (refs.length > 0) + await store.recordAppAccessEvent({ + eventType: "app_access.owner_revoked", + targetId: inspected.access.key, + actorUserId: context.var.actor.id, + payload: { app: inspected.access.key, bot: agentId, refs }, + }); + return context.json({ revoked: refs }); + }, + ); + + /** + * The apps this person could give their own Bots: what the New Bot sheet offers ticked, and the + * switches on a Bot's page. Empty where an administrator switched the feature off for them. + */ + routes.get("/giveable", requireUser, async (context) => { + if (!(await ownerAppsAllowed(context.var.actor.id))) + return context.json({ apps: [] }); + return context.json({ + apps: await store.giveableApps({ + userId: context.var.actor.id, + composioConfigured, + }), + }); + }); + + /** + * Who may give an app to a Bot: any Bot's owner (`any`), only an administrator (`admin`), or the + * default for the app's account (`null`). Admin only, and in the trail. + */ + routes.put("/servers/:id/owner-grants", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + const body = (await context.req.json().catch(() => null)) as { + ownerGrants?: unknown; + } | null; + const value = body?.ownerGrants; + if (value !== null && value !== "any" && value !== "admin") + return context.json( + { error: "Choose any, admin, or null for the default." }, + 400, + ); + const changed = await store.setOwnerGrants({ + serverId: context.req.param("id"), + ownerGrants: value as OwnerGrants | null, + by: actorEmail(context), + }); + if (!changed) + return context.json({ error: "That app hasn't been added." }, 404); + return context.json({ ok: true }); + }); + /** The person's own pending requests, newest first. */ routes.get("/access-requests/mine", requireUser, async (context) => context.json({ diff --git a/server/src/plugins/route-groups/grants.ts b/server/src/plugins/route-groups/grants.ts index 94dd4011f8..61ebc11e98 100644 --- a/server/src/plugins/route-groups/grants.ts +++ b/server/src/plugins/route-groups/grants.ts @@ -5,6 +5,7 @@ import { exposureOf, type SharedUseApproval, } from "../shared-use"; +import { ownerGrantRefusal } from "../owner-grants"; import { CatalogueEntryUnknownError, isDeploymentFault, @@ -60,6 +61,7 @@ export function mountGrantRoutes({ requireUser, canUseBot, shared, + composio, actorEmail, skillActor, }: PluginRouteContext) { @@ -83,10 +85,42 @@ export function mountGrantRoutes({ ? (value as PluginKind) : null; + /** + * May this Bot's owner, who is not an administrator, give it this one action of an app? + * + * The Bot is asked about first, so somebody who does not own it learns nothing about the app from + * the answer. The ref has to name a row that is the app, not merely a word an app answers to. + */ + async function ownerMcpRefusal( + context: { var: AppVariables }, + ref: string, + agentId: string, + ): Promise { + const actorId = context.var.actor.id; + if ( + !(await store.agentIsRegistered(agentId)) || + (await store.agentOwner(agentId)) !== actorId + ) + return "Only an admin can choose which Bots use a tool."; + const [serverId] = ref.split("/"); + const inspected = serverId + ? await store.inspectApp({ + app: serverId, + agentId, + userId: actorId, + composioConfigured: Boolean(composio), + }) + : null; + if (!inspected?.rowIds.includes(serverId ?? "")) + return `${serverId} hasn't been added yet. Only an admin can add it.`; + return ownerGrantRefusal(store, actorId, agentId, inspected); + } + /** * May this person put this on that Bot? * - * MCP is an administrator's, always: it reaches another company's system with a stored credential. + * MCP is an administrator's, except one of the owner's own apps on their own Bot (see + * {@link ownerMcpRefusal}): it reaches another company's system with a stored credential. * A skill is an instruction, so somebody may put their own skill on a Bot they own, and neither * half alone is enough. Both are checked here rather than in the store, because this is the only * place that knows who is asking. @@ -111,7 +145,20 @@ export function mountGrantRoutes({ if (kind === "mcp") { if (!actor.isAdmin) { - return "Only an admin can choose which Bots use a tool."; + /* + * A BOT'S OWNER MAY GIVE IT ONE OF THEIR OWN APPS, and nothing else changes for anybody who + * is not an administrator. The owner rule — their Bot, an app an administrator switched on + * and opened to owners, the `ownerApps` capability — is {@link ownerGrantRefusal}'s, the + * same one the card's one press asks. The grant this allows is written `granted_as = owner` + * by the route below, which is what keeps it on the asker's own account. + * + * TAKING AWAY IS NOT RELAXED HERE. An owner takes back what they gave through + * `DELETE /for/:agentId/apps/:app/give`, which removes only the owner's own grants; this + * route's revoke would remove an administrator's as readily, so it stays theirs. + */ + if (intent === "revoke") + return "Only an admin can choose which Bots use a tool."; + return ownerMcpRefusal(context, ref, agentId); } // Taking something away is always allowed: see the note on `intent`. It matters more here // than anywhere else, because the rows this check exists to prevent are the same shape as the @@ -278,8 +325,13 @@ export function mountGrantRoutes({ * A SHARED APP'S ACTION IS GRANTED WITH WHO MAY USE IT, in the one request. See * {@link sharedGrantApproval} for what is recorded when none is sent. */ + /* + * An owner's grant records no Shared approval: who may reach a team account through a Bot is an + * administrator's decision, and the shared-use gate refuses the calls and files their request. + */ + const asOwner = kind === "mcp" && !skillActor(context).isAdmin; const decided = - kind === "mcp" + kind === "mcp" && !asOwner ? await sharedGrantApproval( { store, shared }, grantRef, @@ -293,7 +345,13 @@ export function mountGrantRoutes({ 400, ); } - await store.grant(kind, grantRef, grantAgentId, actorEmail(context)); + await store.grant( + kind, + grantRef, + grantAgentId, + actorEmail(context), + asOwner ? "owner" : "admin", + ); if (decided?.approval && shared) { await shared.use.setApproval({ botId: grantAgentId, From b87271e11c1b4d7c543979f7d75a347eca747dbe Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:27:25 -0300 Subject: [PATCH 24/73] Keep a Team Bot on each teammate's own account for apps its owner gave it --- server/src/team-bots/team-bots.ts | 28 +- .../owner-app-grants.integration.test.ts | 453 ++++++++++++++++++ 2 files changed, 479 insertions(+), 2 deletions(-) create mode 100644 server/tests/owner-app-grants.integration.test.ts diff --git a/server/src/team-bots/team-bots.ts b/server/src/team-bots/team-bots.ts index 40761ce329..f5fa91bab9 100644 --- a/server/src/team-bots/team-bots.ts +++ b/server/src/team-bots/team-bots.ts @@ -10,7 +10,8 @@ * membership-scoped, so the owner cannot read it and it cannot read theirs. Nothing here changes that. * * WHOSE ACCOUNTS. A Team Bot reaches a connected app as its OWNER when the owner has connected it, - * the same for everyone. A teammate's OWN account on a server the owner has not connected is used + * the same for everyone — for an app an ADMINISTRATOR gave it. An app the owner gave it themselves + * (`plugin_grants.granted_as = owner`) always runs on the asking teammate's own account. A teammate's OWN account on a server the owner has not connected is used * only with their consent: Allow once, Always allow for this Bot, or Skip. Everything else about the * call (grant, policy, approvals, audit) is still decided as the teammate who is asking. */ @@ -21,6 +22,7 @@ import type { AgentActor } from "../agents/profile-types"; import type { Database } from "../db/client"; import { agents, users } from "../db/schema/core"; import { agentProfiles } from "../db/schema/coworker"; +import { pluginGrants } from "../db/schema/plugins"; import { teamBotAssignments, teamBotAudience, @@ -153,6 +155,13 @@ export function createTeamBots(options: { * personal account on (a shared credential reaches the same place whoever asks). The owner, where * the owner connected it. The asker's own account only with a standing or one-time consent; * without one the call is refused with the card's marker, and nothing is sent. + * + * NEVER THE OWNER'S FOR A GRANT THE OWNER MADE. Where the owner gave the Bot this action of one of + * their own apps (`granted_as = owner`), the owner's connection is not asked about: the call goes + * on as though the owner had none, which is the asker's own account — with their consent, or + * refused to connect. An owner giving their Bot their Gmail decided about their own mailbox for + * their own use; publishing the Bot afterwards must not turn that into every teammate's access to + * it. Only an administrator's grant can put a Team Bot on its owner's account. */ credentialActorFor(actorId: string, botId: string) { return async (ref: string): Promise => { @@ -160,7 +169,22 @@ export function createTeamBots(options: { const owner = published?.ownerUserId; if (!owner || owner === actorId) return actorId; const serverId = ref.split("/")[0] ?? ""; - if ((await connectedServers(owner)).has(serverId)) return owner; + const [grant] = await database + .select({ grantedAs: pluginGrants.grantedAs }) + .from(pluginGrants) + .where( + and( + eq(pluginGrants.kind, "mcp"), + eq(pluginGrants.ref, ref), + eq(pluginGrants.agentId, botId), + ), + ) + .limit(1); + if ( + grant?.grantedAs !== "owner" && + (await connectedServers(owner)).has(serverId) + ) + return owner; if (!(await connectedServers(actorId)).has(serverId)) return actorId; const [consent] = await database .select({ decision: teamBotConsents.decision }) diff --git a/server/tests/owner-app-grants.integration.test.ts b/server/tests/owner-app-grants.integration.test.ts new file mode 100644 index 0000000000..b1319c9455 --- /dev/null +++ b/server/tests/owner-app-grants.integration.test.ts @@ -0,0 +1,453 @@ +// server/tests/owner-app-grants.integration.test.ts +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, like } from "drizzle-orm"; +import { Hono, type MiddlewareHandler } from "hono"; +import { createAgentProfileStore } from "../src/agents/profile-store"; +import type { AgentActor } from "../src/agents/profile-types"; +import type { AuditEventInput, AuditStore } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + brokeredConnections, + mcpServers, + mcpTools, + pluginGrants, + teamBotConsents, + teamBotPublications, + users, +} from "../src/db/schema"; +import type { AppAccess } from "../src/plugins/app-access"; +import type { GiveableApp } from "../src/plugins/plugin-store/app-access"; +import { createPluginRoutes } from "../src/plugins/routes"; +import { createSharedUseStore } from "../src/plugins/shared-use-store"; +import { createPluginStore, PluginRefusedError } from "../src/plugins/store"; +import { + createTeamBots, + TEAM_BOT_CONSENT_MARKER, +} from "../src/team-bots/team-bots"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { stubBroker } from "./support/stub-broker"; + +/** + * A Bot's owner giving one of their own apps to their own Bot — and the one rule that makes that + * safe to publish: a grant the owner made runs on the ASKING person's own account, never the + * owner's, even on the owner's published Team Bot. + * + * Real tables, real routes, the real Team Bot credential rule. The broker is the one thing faked, + * and nothing here reaches it past the directory. + */ + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const id = (name: string) => `${name}-${suite}`; + +const owner = id("owner"); +const teammate = id("teammate"); +const admin = id("admin"); +const people = [owner, teammate, admin]; + +const slug = (name: string) => `${name}_${suite}`; +const GMAIL = `composio-${slug("gmail")}`; +const SLACK = `composio-${slug("slack")}`; + +const events: AuditEventInput[] = []; +const audit: AuditStore = { + insert: async (event) => { + events.push(event); + }, +}; + +const stub = stubBroker({ + grantIsLocal: true, + authorizations: null, + listApps: async () => [], +}); +afterEach(() => { + stub.expectNothingUnprovidedReached(); +}); + +const store = createPluginStore({ + brokerProvider: "composio", + database, + auditStore: audit, + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker: stub.broker, + deploymentId: `acme-${suite}`, +}); + +const profiles = createAgentProfileStore( + database, + new URL("https://managed.example.test/ag-ui"), +); + +/** Which servers each person holds their own account on, as `index.ts` reads it for Team Bots. */ +async function connectedServers(userId: string): Promise> { + const rows = await database + .select({ app: brokeredConnections.app }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.holder, "person"), + eq(brokeredConnections.userId, userId), + ), + ); + return new Set(rows.map((row) => `composio-${row.app}`)); +} +const teamBots = createTeamBots({ database, connectedServers }); + +function appAs(actorId: string, role: "admin" | "user" = "user") { + const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { + id: actorId, + email: `${actorId}@example.test`, + role, + } as never); + await next(); + }; + return new Hono().route( + "/api/plugins", + createPluginRoutes( + store, + signedIn, + async () => true, + undefined, + { broker: stub.broker, provider: "composio" }, + { modes: {} as never, use: createSharedUseStore(database, "composio") }, + ), + ); +} + +const send = (method: string, body?: unknown) => ({ + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), +}); + +async function seedApp( + key: string, + title: string, + accountMode: "personal" | "shared" = "personal", +) { + await database.insert(mcpServers).values({ + id: key, + title, + vendor: "Composio", + url: `composio://${key.replace(/^composio-/, "")}`, + provenance: "composio", + authScheme: "OAUTH2", + accountMode, + sharedVendorUserId: + accountMode === "shared" ? `openbot-deployment:${suite}` : null, + }); + await database.insert(mcpTools).values([ + { + serverId: key, + name: "FETCH", + description: "Read", + inputSchema: {}, + effect: "read", + }, + { + serverId: key, + name: "SEND", + description: "Send", + inputSchema: {}, + effect: "write", + }, + ]); +} + +async function connect(userId: string, name = "gmail") { + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug(name), + holder: "person", + userId, + vendorUserId: `vendor-${userId}`, + verified: true, + }); +} + +const createdBots: string[] = []; +async function botOf(actorId: string) { + const profile = await profiles.create( + { id: actorId, role: "user" } satisfies AgentActor, + { + name: "Research Helper", + title: "Research", + roleDescription: "Finds things out.", + visibility: "private", + }, + ); + createdBots.push(profile.id); + return profile.id; +} + +async function stateOf(actorId: string, botId: string, app = GMAIL) { + const response = await appAs(actorId).request( + `/api/plugins/for/${botId}/apps/${app}`, + ); + return ((await response.json()) as { app: AppAccess }).app; +} + +async function grantsOn(botId: string) { + return database + .select({ ref: pluginGrants.ref, grantedAs: pluginGrants.grantedAs }) + .from(pluginGrants) + .where(eq(pluginGrants.agentId, botId)); +} + +async function clear() { + await database + .delete(teamBotConsents) + .where(inArray(teamBotConsents.userId, people)); + if (createdBots.length) { + await database + .delete(teamBotPublications) + .where(inArray(teamBotPublications.agentId, createdBots)); + await database + .delete(agentProfiles) + .where(inArray(agentProfiles.agentId, createdBots.splice(0))); + } + await database + .delete(brokeredConnections) + .where(like(brokeredConnections.app, `%${suite}%`)); + await database.delete(mcpServers).where(like(mcpServers.id, `%${suite}%`)); + await database.delete(agents).where(like(agents.name, "Research Helper")); +} + +beforeAll(async () => { + for (const user of people) + await database.insert(users).values({ + id: user, + email: `${user}@example.test`, + name: user, + emailVerified: true, + }); +}); + +beforeEach(async () => { + events.length = 0; + await clear(); + await seedApp(GMAIL, "Gmail"); +}); + +afterAll(async () => { + await clear(); + await database.delete(users).where(inArray(users.id, people)); +}); + +describe("an owner giving their own app to their own Bot", () => { + test("the card's state is theirs to give, and asks them to connect first when they haven't", async () => { + const bot = await botOf(owner); + expect(await stateOf(owner, bot)).toMatchObject({ + state: "owner_grantable", + requestKind: null, + ownerGrants: "any", + needsConnect: true, + }); + await connect(owner); + expect(await stateOf(owner, bot)).toMatchObject({ + state: "owner_grantable", + needsConnect: false, + }); + }); + + test("one press gives the read-only actions, marked as the owner's, and leaves a trail", async () => { + const bot = await botOf(owner); + await connect(owner); + const given = await appAs(owner).request( + `/api/plugins/for/${bot}/apps/${GMAIL}/give`, + send("POST"), + ); + expect(given.status).toBe(200); + expect(await given.json()).toEqual({ granted: [`${GMAIL}/FETCH`] }); + expect(await grantsOn(bot)).toEqual([ + { ref: `${GMAIL}/FETCH`, grantedAs: "owner" }, + ]); + expect(await stateOf(owner, bot)).toMatchObject({ + state: "ready", + givenByOwner: true, + }); + expect( + events.find((event) => event.eventType === "app_access.owner_granted"), + ).toMatchObject({ actorUserId: owner, payload: { bot, app: GMAIL } }); + }); + + test("somebody else's Bot, an app kept for admins, and an app nobody turned on are refused", async () => { + const bot = await botOf(owner); + const others = await appAs(teammate).request( + `/api/plugins/for/${bot}/apps/${GMAIL}/give`, + send("POST"), + ); + expect(others.status).toBe(403); + expect(await stateOf(teammate, bot)).toMatchObject({ + state: "not_granted", + requestKind: "grant", + }); + + await seedApp(SLACK, "Slack", "shared"); + const shared = await appAs(owner).request( + `/api/plugins/for/${bot}/apps/${SLACK}/give`, + send("POST"), + ); + expect(shared.status).toBe(403); + expect(((await shared.json()) as { error: string }).error).toBe( + "Only an admin can give Slack to a Bot.", + ); + + // The admin opens Slack up; now it is the owner's to give. + const opened = await appAs(admin, "admin").request( + `/api/plugins/servers/${SLACK}/owner-grants`, + send("PUT", { ownerGrants: "any" }), + ); + expect(opened.status).toBe(200); + expect((await stateOf(owner, bot, SLACK)).state).toBe("owner_grantable"); + expect( + events.find( + (event) => + (event.payload as { change?: string }).change === + "owner_grants_changed", + ), + ).toMatchObject({ targetId: SLACK, payload: { from: null, to: "any" } }); + + // And an owner can never decide the rule themselves. + const notAdmin = await appAs(owner).request( + `/api/plugins/servers/${GMAIL}/owner-grants`, + send("PUT", { ownerGrants: "admin" }), + ); + expect(notAdmin.status).toBe(403); + + // An app this deployment never added stays an admin's to turn on. + await database.delete(mcpServers).where(eq(mcpServers.id, GMAIL)); + const absent = await appAs(owner).request( + `/api/plugins/grants`, + send("POST", { kind: "mcp", ref: `${GMAIL}/FETCH`, agentId: bot }), + ); + expect(absent.status).toBe(403); + expect(await grantsOn(bot)).toEqual([]); + }); + + test("the grant route lets an owner add one action, and only the owner's grants can be taken back by them", async () => { + const bot = await botOf(owner); + const added = await appAs(owner).request( + "/api/plugins/grants", + send("POST", { kind: "mcp", ref: `${GMAIL}/SEND`, agentId: bot }), + ); + expect(added.status).toBe(200); + // An admin's grant beside it, which the owner did not give and cannot take. + await store.grant("mcp", `${GMAIL}/FETCH`, bot, "admin@example.test"); + expect(await stateOf(owner, bot)).toMatchObject({ givenByOwner: false }); + + const viaGrants = await appAs(owner).request( + `/api/plugins/grants?kind=mcp&ref=${GMAIL}/SEND&agentId=${bot}`, + send("DELETE"), + ); + expect(viaGrants.status).toBe(403); + + const taken = await appAs(owner).request( + `/api/plugins/for/${bot}/apps/${GMAIL}/give`, + send("DELETE"), + ); + expect(await taken.json()).toEqual({ revoked: [`${GMAIL}/SEND`] }); + expect(await grantsOn(bot)).toEqual([ + { ref: `${GMAIL}/FETCH`, grantedAs: "admin" }, + ]); + }); + + test("the apps a person could give are the ones they connected, and open to owners", async () => { + await seedApp(SLACK, "Slack", "shared"); + const listed = async () => + ( + (await ( + await appAs(owner).request("/api/plugins/giveable") + ).json()) as { apps: GiveableApp[] } + ).apps.filter((app) => app.key.includes(suite)); + expect(await listed()).toEqual([]); + await connect(owner); + expect(await listed()).toEqual([ + { key: GMAIL, displayName: "Gmail", logo: null, account: "personal" }, + ]); + }); +}); + +describe("a Team Bot whose owner gave it their own Gmail", () => { + test("a teammate's call uses the teammate's own account or asks them to connect, never the owner's", async () => { + const bot = await botOf(owner); + await connect(owner); + const given = await appAs(owner).request( + `/api/plugins/for/${bot}/apps/${GMAIL}/give`, + send("POST"), + ); + expect(given.status).toBe(200); + await teamBots.publish({ id: owner, role: "user" }, bot, { + audience: "team", + emails: [], + groups: [], + }); + const ref = `${GMAIL}/FETCH`; + const teammateCall = teamBots.credentialActorFor(teammate, bot); + + // Not connected: the teammate's own (absent) account, which the call then asks them to connect. + expect(await teammateCall(ref)).toBe(teammate); + expect(await stateOf(teammate, bot)).toMatchObject({ + state: "not_connected", + accountHolder: "you", + }); + + // Connected: still the teammate's own, behind their consent, never the owner's. + await connect(teammate); + const asked = await teammateCall(ref).catch((error: unknown) => error); + expect(asked).toBeInstanceOf(PluginRefusedError); + expect((asked as Error).message).toStartWith(TEAM_BOT_CONSENT_MARKER); + await teamBots.consent( + { id: teammate, role: "user" }, + bot, + GMAIL, + "allow_always", + ); + expect(await teammateCall(ref)).toBe(teammate); + expect(await stateOf(teammate, bot)).toMatchObject({ + state: "ready", + accountHolder: "you", + }); + + // The owner's own calls are theirs, as on any Bot of their own. + expect(await teamBots.credentialActorFor(owner, bot)(ref)).toBe(owner); + }); + + test("the same Bot given Gmail by an admin uses the owner's account, as before", async () => { + const bot = await botOf(owner); + await connect(owner); + await connect(teammate); + await store.grant("mcp", `${GMAIL}/FETCH`, bot, "admin@example.test"); + await teamBots.publish({ id: owner, role: "user" }, bot, { + audience: "team", + emails: [], + groups: [], + }); + expect( + await teamBots.credentialActorFor(teammate, bot)(`${GMAIL}/FETCH`), + ).toBe(owner); + expect(await stateOf(teammate, bot)).toMatchObject({ + state: "ready", + accountHolder: "owner", + }); + }); +}); From d45122bf3b3349b65bd3468e22c94543149fb6a5 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:30:27 -0300 Subject: [PATCH 25/73] Restore the canvas and image prototypes the calm-start tests stub --- app/tests/computer-calm-start.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/tests/computer-calm-start.test.tsx b/app/tests/computer-calm-start.test.tsx index 83dcd74fcc..98e3694298 100644 --- a/app/tests/computer-calm-start.test.tsx +++ b/app/tests/computer-calm-start.test.tsx @@ -62,12 +62,18 @@ class SocketDouble { let originalFetch: typeof fetch; let originalWebSocket: typeof WebSocket; let originalIntersectionObserver: typeof IntersectionObserver | undefined; +// Prototypes outlive this file: bun runs every test file in one process, and a canvas stub left +// behind here breaks the next file that draws on one. +let originalGetContext: typeof HTMLCanvasElement.prototype.getContext; +let originalDecode: typeof HTMLImageElement.prototype.decode; beforeAll(() => { GlobalRegistrator.register(); originalFetch = globalThis.fetch; originalWebSocket = globalThis.WebSocket; originalIntersectionObserver = globalThis.IntersectionObserver; + originalGetContext = HTMLCanvasElement.prototype.getContext; + originalDecode = HTMLImageElement.prototype.decode; globalThis.WebSocket = SocketDouble as unknown as typeof WebSocket; // Without an observer the preview counts as on screen, which is what these cases are about. Reflect.deleteProperty(globalThis, "IntersectionObserver"); @@ -86,6 +92,8 @@ afterAll(() => { if (originalIntersectionObserver) { globalThis.IntersectionObserver = originalIntersectionObserver; } + HTMLCanvasElement.prototype.getContext = originalGetContext; + HTMLImageElement.prototype.decode = originalDecode; GlobalRegistrator.unregister(); }); From c2c0543b762c486319c074dff109f8a0ec090e9c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:30:50 -0300 Subject: [PATCH 26/73] Keep what a conversation waits on in the roster and in the open channel's read Busy events now carry the whole work list. It replaces the row's list in the roster and the channel read the conversation screen uses, and both are refetched with the roster after a reconnect or a resync. The sidebar stays lit while a hop or a routine is pending between turns. --- .../components/app-sidebar/app-sidebar.tsx | 3 +- app/src/lib/channels/queries.ts | 46 ++++++++++- app/src/lib/channels/use-channel-events.ts | 79 ++++++++++++++++++- app/tests/channel-event-patch.test.ts | 62 +++++++++++++++ 4 files changed, 181 insertions(+), 9 deletions(-) diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index 47e336b056..30087fc613 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -210,7 +210,8 @@ function ChannelRow({ } pinned={channel.pinned} unread={unread} - busy={channel.busy ?? false} + // A turn running, or work the conversation waits on between turns: a hop, a routine. + busy={(channel.busy ?? false) || (channel.work?.length ?? 0) > 0} /> ); diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts index e8ef1a2f08..b7e2b9ee15 100644 --- a/app/src/lib/channels/queries.ts +++ b/app/src/lib/channels/queries.ts @@ -21,8 +21,44 @@ export type AgentChannel = { * history, and one whose history this deployment cannot reach. See `channel-chat.tsx`. */ lastMessageAt: string | null; + /** + * What this conversation is waiting on right now, oldest first. See `ChannelWork`. + * + * Read with the channel and replaced whole by every busy event, so a reload shows the same thing + * the socket last said. Absent from a server that predates it, which reads as nothing. + */ + work?: ChannelWork[]; }; +/** + * Something a conversation is waiting on that nobody's browser is running: a Bot its Bot asked, + * answering out of sight, or a routine firing into it. + * + * The server reads it from the hop queue and the routine runs, so it holds for as long as the work + * does, across reloads and replicas. `since` is when it started, for the timer a person watches. + */ +export type ChannelWork = + | { + kind: "handoff"; + key: string; + /** The Bot that was asked. */ + botId: string; + /** Its name when it was asked, or null. Prefer the roster's name when the person has one. */ + botName: string | null; + /** ISO-8601. */ + since: string; + } + | { + kind: "routine"; + key: string; + routineId: string; + /** The Bot the routine runs as. */ + botId: string; + botName: string | null; + /** ISO-8601. */ + since: string; + }; + /** A channel plus what the roster renders about it. */ export type ChannelSummary = AgentChannel & { /** A few words about the conversation, or null. The roster falls back to `name`. */ @@ -38,10 +74,10 @@ export type ChannelSummary = AgentChannel & { /** * Whether a turn is running in this channel right now. * - * Socket-only and transient: the server never persists it and the roster query never returns it, - * so it is undefined until a busy event arrives and is dropped whenever the roster is refetched. - * A headless turn — a handoff hop, a relay — sets it, which is how the roster shows work the - * browser never streamed. + * Mostly socket-only and transient: a person's own run is announced and never stored, so a + * refetch drops it. The roster returns it true for a channel with `work`, which is stored, so a + * conversation a Bot is still answering in stays lit across a reload. A headless turn — a handoff + * hop, a relay — sets it, which is how the roster shows work the browser never streamed. */ busy?: boolean; }; @@ -49,6 +85,8 @@ export type ChannelSummary = AgentChannel & { export const channelKeys = { all: ["channels"] as const, list: () => ["channels", "list"] as const, + /** Every channel read by id, to refetch them together. */ + details: () => ["channels", "detail"] as const, detail: (channelId: string) => ["channels", "detail", channelId] as const, }; diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts index 3c8a9dd332..8b3a07819a 100644 --- a/app/src/lib/channels/use-channel-events.ts +++ b/app/src/lib/channels/use-channel-events.ts @@ -1,7 +1,13 @@ import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "@tanstack/react-router"; import { useEffect } from "react"; -import { type ChannelPage, type ChannelSummary, channelKeys } from "./queries"; +import { + type AgentChannel, + type ChannelPage, + type ChannelSummary, + type ChannelWork, + channelKeys, +} from "./queries"; import { socketUrl as buildSocketUrl } from "@/lib/socket-url"; /** @@ -90,6 +96,12 @@ export type ChannelActivityEvent = { * indicator without disturbing the preview or the order. */ busy?: boolean; + /** + * Everything the channel is waiting on now, replacing what the row held. Absent on an ordinary + * activity event. Rides on every busy event, and alone on the one the server sends when a hop + * settles without a turn starting or ending. + */ + work?: ChannelWork[]; }; /** The infinite query's cache, which holds pages rather than one array. */ @@ -171,10 +183,20 @@ export function applyChannelEvent( * is also not activity — a channel does not jump to the top of the roster because a turn started * in it — so the order is left exactly as it was. */ - if (activity.busy !== undefined) { - if ((previous.busy ?? false) === activity.busy) return data; + if (activity.busy !== undefined || activity.work !== undefined) { + const busy = activity.busy ?? previous.busy; + const work = activity.work ?? previous.work; + if ( + (previous.busy ?? false) === (busy ?? false) && + sameWork(previous.work, work) + ) + return data; const channels = page.channels.slice(); - channels[index] = { ...previous, busy: activity.busy }; + channels[index] = { + ...previous, + ...(busy === undefined ? {} : { busy }), + ...(work === undefined ? {} : { work }), + }; const pages = data.pages.slice(); pages[holdingPage] = { ...page, channels }; return { ...data, pages }; @@ -194,6 +216,43 @@ export function applyChannelEvent( return { ...data, pages }; } +/** + * The same list, entry for entry. Compared rather than replaced so an event that changes nothing + * leaves the cache alone and the transcript does not re-render to draw the same rows. + */ +function sameWork( + left: readonly ChannelWork[] | undefined, + right: readonly ChannelWork[] | undefined, +): boolean { + const a = left ?? []; + const b = right ?? []; + return ( + a.length === b.length && + a.every( + (entry, at) => + entry.key === b[at]?.key && + entry.since === b[at]?.since && + entry.botName === b[at]?.botName, + ) + ); +} + +/** + * Apply one event's work list to the open conversation's own read. + * + * The conversation screen reads the channel by id rather than from the roster: the roster is paged, + * and the conversation somebody has open need not be on a page they have loaded. So the list is + * patched there too, and only there when the event carries one. + */ +export function applyChannelWork( + channel: AgentChannel | undefined, + activity: ChannelActivityEvent, +): AgentChannel | undefined { + if (!channel || activity.work === undefined) return channel; + if (sameWork(channel.work, activity.work)) return channel; + return { ...channel, work: activity.work }; +} + const FIRST_RETRY_MS = 500; const MAX_RETRY_MS = 30_000; @@ -219,6 +278,7 @@ export function useChannelEvents() { retryDelay = FIRST_RETRY_MS; // Recover events missed while the socket was disconnected. void queryClient.invalidateQueries({ queryKey: channelKeys.list() }); + void queryClient.invalidateQueries({ queryKey: channelKeys.details() }); }; socket.onmessage = (message) => { @@ -229,6 +289,9 @@ export function useChannelEvents() { // `channelId`, because this message has none. if (isResync(parsed)) { void queryClient.invalidateQueries({ queryKey: channelKeys.list() }); + void queryClient.invalidateQueries({ + queryKey: channelKeys.details(), + }); return; } @@ -260,6 +323,14 @@ export function useChannelEvents() { }, ); + if (activity.work !== undefined) { + queryClient.setQueryData( + channelKeys.detail(activity.channelId), + (channel: AgentChannel | undefined) => + applyChannelWork(channel, activity), + ); + } + /* * A tab looking at the channel somebody just deleted in another tab. * diff --git a/app/tests/channel-event-patch.test.ts b/app/tests/channel-event-patch.test.ts index cc850820d7..93c7ae2bc4 100644 --- a/app/tests/channel-event-patch.test.ts +++ b/app/tests/channel-event-patch.test.ts @@ -225,6 +225,68 @@ describe("a busy signal", () => { }); }); +/** + * What the channel is waiting on, carried whole on every busy event and alone on a settle. + * + * Replaced rather than merged, so the newest event is the answer and a retried hop never shows + * twice. The sidebar lights from it as well as from `busy`, so a hop still running after a turn + * ended keeps the row lit. + */ +describe("a work list", () => { + const work = [ + { + kind: "handoff" as const, + key: "hop-1", + botId: "ana", + botName: "Ana", + since: "2026-10-26T10:00:00.000Z", + }, + ]; + + test("rides on a busy event and patches only the busy flag and the list", () => { + const data = cache([ + channel("a", { lastMessage: "Said something." }), + channel("b"), + ]); + + const patched = applyChannelEvent( + data, + event({ channelId: "a", busy: true, work }), + ); + + expect(patched).not.toBe("unknown"); + if (patched === "unknown") return; + expect(patched.pages[0]?.channels[0]).toEqual({ + ...(data.pages[0]?.channels[0] as ChannelSummary), + busy: true, + work, + }); + }); + + test("a settle replaces the list and leaves the busy flag as it was", () => { + const patched = applyChannelEvent( + cache([channel("a", { busy: true, work })]), + event({ channelId: "a", work: [] }), + ); + + expect(patched).not.toBe("unknown"); + if (patched === "unknown") return; + expect(patched.pages[0]?.channels[0]?.busy).toBe(true); + expect(patched.pages[0]?.channels[0]?.work).toEqual([]); + }); + + test("the same list again changes nothing", () => { + const data = cache([channel("a", { busy: true, work })]); + + expect( + applyChannelEvent( + data, + event({ channelId: "a", busy: true, work: [...work] }), + ), + ).toBe(data); + }); +}); + /** * A conversation the server has just named. * From 9a0f6c246e5c8fef1c0185b80ef4c3e5fb3ae00f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:30:50 -0300 Subject: [PATCH 27/73] Show who a conversation is waiting on, with a running clock, where the answer will land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Asking Ana… 0:12" for a Bot its Bot asked, "Ana is running a routine…" for a routine, at the end of the transcript, from the server's list. Hidden while this tab's own turn runs. ProgressRow is the reusable line for later waits. --- app/src/components/channels/channel-chat.tsx | 18 ++ app/src/components/channels/channel-work.tsx | 42 +++ .../components/channels/chat-transcript.tsx | 8 + .../components/channels/conversation-view.tsx | 4 + app/src/components/channels/progress-row.tsx | 60 +++++ app/tests/channel-work-row.test.tsx | 240 ++++++++++++++++++ 6 files changed, 372 insertions(+) create mode 100644 app/src/components/channels/channel-work.tsx create mode 100644 app/src/components/channels/progress-row.tsx create mode 100644 app/tests/channel-work-row.test.tsx diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 06bb23cdb9..74d67cd8d7 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { HandoffResumeNotice } from "@/components/computer/handoff-resume-notice"; import { attachmentModality } from "@/components/channels/chat-messages"; import { toAgentOptions } from "@/components/channels/composer"; +import { ChannelWorkRows } from "@/components/channels/channel-work"; import { ConversationView } from "@/components/channels/conversation-view"; import { VoiceCallWidget } from "@/components/channels/voice-call-widget"; import { @@ -1081,6 +1082,23 @@ export function ChannelChat({ * run before closing it; see server/src/channels/stall-guard.ts. */ stopped={runError ?? undefined} + /* + * What the server says this conversation waits on: a Bot its Bot asked, a routine. + * Hidden while this tab's own turn is going, which already shows itself and would + * otherwise draw "Asking Ana…" under the sentence saying it had asked. The composer + * stays open either way: a person may keep talking while a hop runs, and a relay that + * finds them mid-turn waits and is tried again. + */ + working={ + agent.isRunning || turnsInFlight > 0 ? null : ( + + agentProfiles?.find((profile) => profile.id === botId)?.name + } + work={channel.work} + /> + ) + } /> diff --git a/app/src/components/channels/channel-work.tsx b/app/src/components/channels/channel-work.tsx new file mode 100644 index 0000000000..43c32464fd --- /dev/null +++ b/app/src/components/channels/channel-work.tsx @@ -0,0 +1,42 @@ +import type { ChannelWork } from "@/lib/channels/queries"; +import { ProgressRow } from "./progress-row"; + +/** + * What a conversation is waiting on, one row each, at the end of the transcript. + * + * Driven by the server's list rather than this tab's run, because the work is not this tab's: a Bot + * the conversation's Bot asked answers out of sight, and a routine fires with nobody's browser + * open. Without these the conversation looks finished while something is still working on it. + * + * Gone when the list empties, which is when the answer is being written into the conversation or + * the work is over. The caller hides it while its own run is going, so a turn that has just asked + * another Bot does not show "Asking Ana…" under its own half-written reply. + */ +export function ChannelWorkRows({ + work, + nameOf, +}: { + work: readonly ChannelWork[] | undefined; + /** The name this person knows a Bot by, when they know it at all. */ + nameOf?: (botId: string) => string | undefined; +}) { + if (!work || work.length === 0) return null; + return ( +
+ {work.map((entry) => ( + + ))} +
+ ); +} + +function labelFor(entry: ChannelWork, name: string | null): string { + if (entry.kind === "handoff") { + return name ? `Asking ${name}…` : "Asking another Bot…"; + } + return name ? `${name} is running a routine…` : "A routine is running…"; +} diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index b439b5c5d7..d8466cce92 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -106,6 +106,12 @@ type ChatTranscriptProps = { * be told, and only the thing that ended the turn knows which one happened. */ stopped?: string; + /** + * Work this conversation is waiting on that is not this tab's run: a Bot it asked, a routine. + * Drawn in the same slot as `Thinking`, under the last thing said, because that is where the + * answer will appear. + */ + working?: React.ReactNode; }; /** One shared empty array, so a screen without a queue does not hand down a new one per render. */ @@ -1549,6 +1555,7 @@ export function ChatTranscript({ queued = EMPTY_QUEUE, restoring = false, stopped, + working, }: ChatTranscriptProps) { const isDrawn = useDrawnTool(); /* @@ -1692,6 +1699,7 @@ export function ChatTranscript({ ) : waitingOnFirstToken ? ( ) : null} + {working} {queued.map((message) => (
diff --git a/app/src/components/channels/progress-row.tsx b/app/src/components/channels/progress-row.tsx new file mode 100644 index 0000000000..3160ebfac5 --- /dev/null +++ b/app/src/components/channels/progress-row.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; + +/** + * One line saying something is under way, and for how long. + * + * For a wait the person did not start and cannot watch: a Bot asked by their Bot, a routine firing, + * a sign-in or an approval somebody else has to finish. Drawn where the result will appear, so the + * person looks at the right spot, and with a running clock, because "still working" and "stuck" + * look the same without one. + * + * It borrows the shimmer a running tool line uses, so every kind of "working on it" in the + * transcript reads the same. + */ +export function ProgressRow({ + label, + since, +}: { + /** What is under way, as a person would say it: "Asking Ana…". */ + label: string; + /** When it started. ISO-8601, or epoch milliseconds. */ + since: string | number; +}) { + const elapsed = useElapsed(since); + return ( +

+ {label} + +

+ ); +} + +/** "0:12", "4:05", "1:02:07". Never negative: a server clock a little ahead reads as zero. */ +export function formatElapsed(milliseconds: number): string { + const total = Math.max(0, Math.floor(milliseconds / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = String(total % 60).padStart(2, "0"); + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` + : `${minutes}:${seconds}`; +} + +/** The time since `since`, as text, moving once a second while it is on screen. */ +function useElapsed(since: string | number): string { + const start = typeof since === "number" ? since : Date.parse(since); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + return formatElapsed(Number.isNaN(start) ? 0 : now - start); +} diff --git a/app/tests/channel-work-row.test.tsx b/app/tests/channel-work-row.test.tsx new file mode 100644 index 0000000000..9ef09032e2 --- /dev/null +++ b/app/tests/channel-work-row.test.tsx @@ -0,0 +1,240 @@ +import { + afterAll, + afterEach, + beforeAll, + expect, + jest, + setSystemTime, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { + QueryClient, + QueryClientProvider, + useQuery, +} from "@tanstack/react-query"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { ChannelWorkRows } from "@/components/channels/channel-work"; +import { formatElapsed } from "@/components/channels/progress-row"; +import { + type AgentChannel, + type ChannelWork, + channelKeys, + channelQueryOptions, +} from "@/lib/channels/queries"; +import { applyChannelWork } from "@/lib/channels/use-channel-events"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The row a conversation shows while it waits on work nobody's browser is running: "Asking Ana… + * 0:12" for a Bot its Bot asked, and the same for a routine. Driven by the server's list, so it + * has to appear from a fetched channel as well as from a socket event, tick while it is up, and go + * when the list empties. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(() => { + cleanup(); + jest.useRealTimers(); + setSystemTime(); +}); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +const NOW = new Date("2026-10-26T10:00:12.000Z"); + +const asking = (overrides: Partial = {}): ChannelWork => + ({ + kind: "handoff", + key: "hop-1", + botId: "ana", + botName: "Ana", + since: "2026-10-26T10:00:00.000Z", + ...overrides, + }) as ChannelWork; + +test("says who was asked and for how long", () => { + setSystemTime(NOW); + + const view = render(); + + const row = view.getByTestId("progress-row"); + expect(row.textContent).toBe("Asking Ana…0:12"); + // Announced as progress, and only by its words: the clock is hidden from a screen reader. + expect(row.getAttribute("role")).toBe("status"); + expect(row.querySelector("[aria-hidden='true']")?.textContent).toBe("0:12"); +}); + +test("the clock moves while the row is up", () => { + jest.useFakeTimers(); + setSystemTime(NOW); + const view = render(); + + // The fake clock moves `Date.now()` with the timers, as a real second would. + act(() => { + jest.advanceTimersByTime(3_000); + }); + + expect(view.getByTestId("progress-row").textContent).toBe("Asking Ana…0:15"); +}); + +test("prefers the name the person knows the Bot by", () => { + setSystemTime(NOW); + + const view = render( + (botId === "ana" ? "Ana Research" : undefined)} + work={[asking()]} + />, + ); + + expect(view.getByTestId("progress-row").textContent).toContain( + "Asking Ana Research…", + ); +}); + +test("one row for each Bot asked, and one for a routine", () => { + setSystemTime(NOW); + + const view = render( + , + ); + + expect( + view.getAllByTestId("progress-row").map((row) => row.textContent), + ).toEqual([ + "Asking Ana…0:12", + "Asking another Bot…0:12", + "Assistant is running a routine…1:12", + ]); +}); + +test("goes when the list empties", () => { + setSystemTime(NOW); + const view = render(); + + view.rerender(); + + expect(view.queryByTestId("progress-row")).toBeNull(); + expect(view.queryByTestId("channel-work")).toBeNull(); +}); + +/* + * A reload is a fetch of the channel, not a socket event. The list has to come back with it, or a + * conversation a Bot is still answering in looks finished to anybody who refreshed. + */ +test("comes back from the channel read after a reload", async () => { + setSystemTime(NOW); + global.fetch = (async (input: RequestInfo | URL) => { + if (String(input) === "/api/channels/channel-1") { + return Response.json({ + channel: { + id: "channel-1", + name: "Assistant", + agentIds: ["assistant"], + threadId: "thread-1", + active: true, + lastMessageAt: null, + work: [asking()], + }, + }); + } + return Response.json({ error: "unexpected" }, { status: 500 }); + }) as typeof fetch; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + function Conversation() { + const channel = useQuery(channelQueryOptions("channel-1")); + return ; + } + + const view = render( + + + , + ); + + await waitFor(() => { + expect(view.getByTestId("progress-row").textContent).toBe( + "Asking Ana…0:12", + ); + }); + + // The socket then says the answer is being given, and the row goes. + act(() => { + queryClient.setQueryData( + channelKeys.detail("channel-1"), + (channel: AgentChannel | undefined) => + applyChannelWork(channel, { + channelId: "channel-1", + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + work: [], + }), + ); + }); + await waitFor(() => { + expect(view.queryByTestId("progress-row")).toBeNull(); + }); +}); + +test("an event with no list leaves the conversation's list alone", () => { + const channel: AgentChannel = { + id: "channel-1", + name: "Assistant", + agentIds: [], + threadId: "thread-1", + active: true, + lastMessageAt: null, + work: [asking()], + }; + + expect( + applyChannelWork(channel, { + channelId: "channel-1", + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + busy: true, + }), + ).toBe(channel); + // And the same list again changes nothing either, so nothing re-renders. + expect( + applyChannelWork(channel, { + channelId: "channel-1", + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + work: [asking()], + }), + ).toBe(channel); +}); + +test("the clock reads minutes, then hours, and never runs backwards", () => { + expect(formatElapsed(12_000)).toBe("0:12"); + expect(formatElapsed(245_000)).toBe("4:05"); + expect(formatElapsed(3_727_000)).toBe("1:02:07"); + expect(formatElapsed(-4_000)).toBe("0:00"); +}); From c48fe9fb1c9b797dd68ddf904674666d2915a981 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:35:46 -0300 Subject: [PATCH 28/73] Let an admin choose on an app's page whether Bot owners can give it --- app/src/lib/plugins/mutations.ts | 23 +++++++++++ app/src/lib/plugins/queries.ts | 8 ++++ app/src/routes/_authed/admin/plugins/$key.tsx | 41 +++++++++++++++++++ .../admin-brokered-row-provider.test.tsx | 2 + app/tests/bot-app-grants-screen.test.tsx | 2 + app/tests/brokered-account-row.test.tsx | 2 + .../connected-account-confirmation.test.tsx | 2 + app/tests/connected-account-grants.test.tsx | 2 + .../connected-account-landing-param.test.tsx | 2 + app/tests/plugin-mutation-refresh.test.ts | 15 +++++++ app/tests/skipped-tools-pages.test.tsx | 2 + 11 files changed, 101 insertions(+) diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts index 2552a1f13a..02d3bacf34 100644 --- a/app/src/lib/plugins/mutations.ts +++ b/app/src/lib/plugins/mutations.ts @@ -194,6 +194,29 @@ export function setAccountModeMutationOptions(queryClient: QueryClient) { }); } +/** + * Who may give an app to a Bot: any Bot's owner (`any`) or only an admin (`admin`). The server writes + * the choice to the trail. + */ +export function setOwnerGrantsMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + serverId: string; + ownerGrants: "any" | "admin"; + }) => { + await client( + `/api/plugins/servers/${encodeURIComponent(variables.serverId)}/owner-grants`, + { + method: "PUT", + body: { ownerGrants: variables.ownerGrants }, + fallback: "Couldn't change who can give this app.", + }, + ); + }, + onSettled: () => invalidatePlugins(queryClient), + }); +} + /** * Whether one Bot carries one plugin. * diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index c3a42ccbaf..3879f67dae 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -118,6 +118,14 @@ export type PluginServer = { * {@link accountMode} is not `"shared"`. */ sharedVendorConnected: boolean; + /** + * Who may give this app to a Bot: any Bot's owner, for their own Bot (`any`), or only an admin + * (`admin`), with the default for its account filled in. Every person's own account defaults to + * `any`, as do the built-in Routines; everything else to `admin`. + */ + ownerGrants: "any" | "admin"; + /** What an admin chose, or null where the default is in force. */ + ownerGrantsChosen: "any" | "admin" | null; }; export type PluginSkill = { diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 24feed509b..c86836471b 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -55,6 +55,7 @@ import { refreshPluginServerMutationOptions, registerOAuthClientMutationOptions, removePluginServerMutationOptions, + setOwnerGrantsMutationOptions, } from "@/lib/plugins/mutations"; import { brokeredConnectionDescription, @@ -301,6 +302,10 @@ function RouteComponent() { ...refreshPluginServerMutationOptions(queryClient), ...report, }); + const ownerGrants = useMutation({ + ...setOwnerGrantsMutationOptions(queryClient), + ...report, + }); const remove = useMutation({ ...removePluginServerMutationOptions(queryClient), ...report, @@ -638,6 +643,42 @@ function RouteComponent() { ) : null} + + {/* + * Who may give the app to a Bot. Binary and immediate, so a Switch. Every person's own + * account and the built-in Routines start on; a deployment token, a shared account and an + * app with no account start off, because a Bot given one of those reaches an account that + * is not the asker's. An owner's grant always runs on the asker's own account. + */} + {server ? ( + <> + + + + Bot owners can give it to their Bots + + {server.ownerGrants === "any" + ? "People can let Bots they own use it, on the account of whoever is asking." + : "Only an admin can give it to a Bot."} + + + + { + setError(null); + ownerGrants.mutate({ + serverId: key, + ownerGrants: next ? "any" : "admin", + }); + }} + /> + + + + ) : null} diff --git a/app/tests/admin-brokered-row-provider.test.tsx b/app/tests/admin-brokered-row-provider.test.tsx index acc104d3b7..3c4a986fd8 100644 --- a/app/tests/admin-brokered-row-provider.test.tsx +++ b/app/tests/admin-brokered-row-provider.test.tsx @@ -80,6 +80,8 @@ function server(provider: Provider, shared: boolean): PluginServer { dynamicClient: false, accountMode: shared ? "shared" : null, sharedVendorConnected: false, + ownerGrants: shared ? "admin" : "any", + ownerGrantsChosen: null, tools: [], withdrawn: [], }; diff --git a/app/tests/bot-app-grants-screen.test.tsx b/app/tests/bot-app-grants-screen.test.tsx index 8c707b7827..21204444ab 100644 --- a/app/tests/bot-app-grants-screen.test.tsx +++ b/app/tests/bot-app-grants-screen.test.tsx @@ -131,6 +131,8 @@ function server(overrides: Partial & { id: string }) { dynamicClient: false, accountMode: null, sharedVendorConnected: false, + ownerGrants: "admin", + ownerGrantsChosen: null, // Not brokered unless a case says otherwise, which is what a null means here. authScheme: null, tools: [], diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx index f9d502c571..65f1bd229b 100644 --- a/app/tests/brokered-account-row.test.tsx +++ b/app/tests/brokered-account-row.test.tsx @@ -179,6 +179,8 @@ function brokeredServer(authScheme: string): PluginServer { dynamicClient: false, accountMode: null, sharedVendorConnected: false, + ownerGrants: "any", + ownerGrantsChosen: null, tools: [], withdrawn: [], }; diff --git a/app/tests/connected-account-confirmation.test.tsx b/app/tests/connected-account-confirmation.test.tsx index 11951a97a3..0fd1905488 100644 --- a/app/tests/connected-account-confirmation.test.tsx +++ b/app/tests/connected-account-confirmation.test.tsx @@ -92,6 +92,8 @@ function server(provider: Provider): PluginServer { dynamicClient: false, accountMode: null, sharedVendorConnected: false, + ownerGrants: "any", + ownerGrantsChosen: null, tools: [], withdrawn: [], }; diff --git a/app/tests/connected-account-grants.test.tsx b/app/tests/connected-account-grants.test.tsx index 77651f58dc..b0dad61542 100644 --- a/app/tests/connected-account-grants.test.tsx +++ b/app/tests/connected-account-grants.test.tsx @@ -94,6 +94,8 @@ function server( dynamicClient: false, accountMode, sharedVendorConnected: false, + ownerGrants: "any", + ownerGrantsChosen: null, tools: [], withdrawn: [], }; diff --git a/app/tests/connected-account-landing-param.test.tsx b/app/tests/connected-account-landing-param.test.tsx index e6e3bde278..47cccb8c0b 100644 --- a/app/tests/connected-account-landing-param.test.tsx +++ b/app/tests/connected-account-landing-param.test.tsx @@ -66,6 +66,8 @@ function server(accountMode: "shared" | null): PluginServer { dynamicClient: false, accountMode, sharedVendorConnected: false, + ownerGrants: "any", + ownerGrantsChosen: null, tools: [], withdrawn: [], }; diff --git a/app/tests/plugin-mutation-refresh.test.ts b/app/tests/plugin-mutation-refresh.test.ts index 3dd72fd7fa..f11bbdd07c 100644 --- a/app/tests/plugin-mutation-refresh.test.ts +++ b/app/tests/plugin-mutation-refresh.test.ts @@ -26,6 +26,7 @@ import { removeSkillMutationOptions, saveSkillMutationOptions, setAccountModeMutationOptions, + setOwnerGrantsMutationOptions, setPluginGrantMutationOptions, } from "../src/lib/plugins/mutations"; import { pluginKeys } from "../src/lib/plugins/queries"; @@ -299,6 +300,14 @@ const REFUSALS: { mode: "shared" as const, }, }, + { + name: "choosing who may give an app to a Bot", + route: "PUT /api/plugins/servers/:id/owner-grants", + status: 404, + message: "That app hasn't been added.", + factory: setOwnerGrantsMutationOptions, + variables: { serverId: "linear", ownerGrants: "admin" as const }, + }, ]; for (const refusal of REFUSALS) { @@ -567,6 +576,12 @@ const ENCODED_REQUESTS: { build: setAccountModeMutationOptions, variables: { serverId: HOSTILE_ID, mode: "shared" as const }, }, + { + name: "choosing who may give an app to a Bot", + url: `/api/plugins/servers/${ENCODED_ID}/owner-grants`, + build: setOwnerGrantsMutationOptions, + variables: { serverId: HOSTILE_ID, ownerGrants: "any" as const }, + }, { /* * The one write whose caller text goes in a QUERY rather than a path, and it takes the same diff --git a/app/tests/skipped-tools-pages.test.tsx b/app/tests/skipped-tools-pages.test.tsx index f51372798f..8fc1632772 100644 --- a/app/tests/skipped-tools-pages.test.tsx +++ b/app/tests/skipped-tools-pages.test.tsx @@ -91,6 +91,8 @@ function server(): PluginServer { dynamicClient: false, accountMode: null, sharedVendorConnected: false, + ownerGrants: "any", + ownerGrantsChosen: null, authScheme: null, tools: [ { From 8c1e8dfa20e5e111211f67992769d3217e450af8 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:35:46 -0300 Subject: [PATCH 29/73] Ask a Bot's owner on the app card whether to let it use their app, in one press --- .../components/channels/app-access-card.tsx | 17 +- .../components/plugins/app-access-action.tsx | 146 +++++++++++++++++- app/src/lib/app-access/mutations.ts | 41 +++++ app/src/lib/app-access/queries.ts | 40 ++++- app/tests/app-access-action.test.tsx | 55 ++++++- app/tests/app-access-fixture.ts | 3 + 6 files changed, 289 insertions(+), 13 deletions(-) diff --git a/app/src/components/channels/app-access-card.tsx b/app/src/components/channels/app-access-card.tsx index e0eff73e31..5a9661be8d 100644 --- a/app/src/components/channels/app-access-card.tsx +++ b/app/src/components/channels/app-access-card.tsx @@ -1,3 +1,4 @@ +import type { ConnectPrompt } from "@openbot/shared/connect-prompt"; import { useQuery } from "@tanstack/react-query"; import { AppAccessAction, @@ -5,10 +6,10 @@ import { } from "@/components/plugins/app-access-action"; import { PluginLogo } from "@/components/plugins/plugin-logo"; import { Button } from "@/components/ui/button"; +import { agentQueryOptions } from "@/lib/agents/queries"; import { type AppAccess, botAppQueryOptions } from "@/lib/app-access/queries"; import { useDeclaredBotId } from "@/lib/copilot/active-bot"; import { type CardPlace, useCarryOn } from "@/lib/copilot/carry-on"; -import type { ConnectPrompt } from "@openbot/shared/connect-prompt"; import { ConnectAppCard } from "./connect-app-card"; /** @@ -76,6 +77,8 @@ function LiveAppAccessCard({ }); const access = live.data ? asRefused(live.data, prompt) : null; const name = access?.displayName ?? prompt.title; + // Only asked for by "Let Research Bot use your Gmail?", the one sentence that names the Bot. + const bot = useQuery(agentQueryOptions(agentId)).data?.name; const { carried, offer } = useCarryOn({ place, ready: access ? access.state === "ready" : null, @@ -111,12 +114,12 @@ function LiveAppAccessCard({ {live.isPending ? null : access ? (

{access.state !== "ready" - ? refusedSentence(access, prompt) + ? refusedSentence(access, prompt, bot) : carried || offer ? `${name} is ready now.` : prompt.reason !== "access" ? `${name} is ready now. Ask again and the Bot can use it.` - : refusedSentence(access, prompt)} + : refusedSentence(access, prompt, bot)}

) : (

@@ -178,12 +181,16 @@ function asRefused(live: AppAccess, prompt: ConnectPrompt): AppAccess { } /** As the shared sentence, except a lost team account, which needs reconnecting rather than connecting. */ -function refusedSentence(access: AppAccess, prompt: ConnectPrompt): string { +function refusedSentence( + access: AppAccess, + prompt: ConnectPrompt, + bot?: string, +): string { if ( access.state === "shared_not_connected" && prompt.reason === "shared" && !access.requestKind ) return `Your organisation's ${access.displayName} account needs reconnecting. Only an admin can reconnect it.`; - return appAccessSentence(access); + return appAccessSentence(access, bot); } diff --git a/app/src/components/plugins/app-access-action.tsx b/app/src/components/plugins/app-access-action.tsx index 42fe1cfbd5..d52311058f 100644 --- a/app/src/components/plugins/app-access-action.tsx +++ b/app/src/components/plugins/app-access-action.tsx @@ -7,6 +7,7 @@ import { agentQueryOptions } from "@/lib/agents/queries"; import { approveBotAppMutationOptions, cancelAppAccessRequestMutationOptions, + giveBotAppMutationOptions, requestAppAccessMutationOptions, } from "@/lib/app-access/mutations"; import { type AppAccess, appAccessKeys } from "@/lib/app-access/queries"; @@ -21,8 +22,9 @@ import { queryClient } from "@/query-client"; * whether this person can use this app through this Bot, whose move it is if not, and the button * for that move — so both draw it from here, and a sentence corrected for one is corrected for both. * - * WHO CAN FIX IT IS THE WHOLE SENTENCE. Seven states reduce to four answers: nothing to do, a step - * that is yours (connect, reconnect), a step only an administrator can take (switch it on, finish + * WHO CAN FIX IT IS THE WHOLE SENTENCE. Eight states reduce to four answers: nothing to do, a step + * that is yours (connect, reconnect, or giving your own app to your own Bot), a step only an + * administrator can take (switch it on, finish * setting it up, grant it, connect or approve the team account), and on somebody else's Team Bot a * step only its owner can take (reconnect the account the Bot runs on). The copy names which, * because "Gmail is unavailable" sends somebody hunting through their own settings for a switch that @@ -34,8 +36,11 @@ import { queryClient } from "@/query-client"; * `AppAccessCard` — and that is the caller's decision, made where the refusal is in hand. */ -/** What the person is told about one app, in words that say whose move it is. */ -export function appAccessSentence(access: AppAccess): string { +/** + * What the person is told about one app, in words that say whose move it is. `bot` is the Bot's name, + * for the one state that asks the person a question about it. + */ +export function appAccessSentence(access: AppAccess, bot?: string): string { const name = access.displayName; const owners = access.accountHolder === "owner"; switch (access.state) { @@ -59,6 +64,11 @@ export function appAccessSentence(access: AppAccess): string { return disabledSentence(access); case "not_granted": return `This Bot hasn't been given ${name}. Only an admin can give it.`; + case "owner_grantable": { + const yours = access.account === "personal" ? `your ${name}` : name; + const ask = `Let ${bot ?? "this Bot"} use ${yours}?`; + return access.needsConnect ? `${ask} Connect it first.` : ask; + } } } @@ -116,6 +126,9 @@ export function AppAccessAction({ return null; if (access.state === "not_connected" || access.state === "reconnect") return ; + // Theirs to give, admin or not: an owner's grant keeps every call on the asker's own account. + if (access.state === "owner_grantable") + return ; if (me?.role === "admin") return ; return ; @@ -217,6 +230,131 @@ function AdminAction({ ); } +/** + * The Bot's owner giving it one of their own apps, as one press: Allow, or Connect and allow where + * the app runs on their own account and they have not connected it. + * + * CONNECT FIRST, THEN GIVE, ON THE SAME PRESS. The consent opens in a new tab the way `ConnectAction` + * opens it, and this tab watches the server until the account is there — `needsConnect` turning false + * — and then gives the app without a second press. Giving first would leave a Bot holding an app + * nobody had connected if the person closed the consent screen, and would swap this button for a + * Connect in the middle of the press. + * + * AN APP CONNECTED WITH A TYPED KEY is connected on its page in Settings, as `ConnectAction` sends it; + * coming back, the app reads connected and the button says Allow. + * + * AND THEN THE TASK CARRIES ON. Every query is read again once the app is given, so the card sees the + * app ready and carries the conversation on (`lib/copilot/carry-on.ts`). + */ +function OwnerGiveAction({ + agentId, + access, +}: { + agentId: string; + access: AppAccess; +}) { + const bot = useQuery(agentQueryOptions(agentId)).data?.name ?? "this Bot"; + const give = useMutation(giveBotAppMutationOptions(queryClient)); + const tab = useConnectInNewTab(); + const { waiting, stopWaiting } = tab; + const name = access.displayName; + /** Pressed while the account still had to be connected: give it as soon as it is. */ + const pending = useRef(false); + + const giveNow = () => + give.mutate( + { agentId, app: access.key }, + { onSuccess: () => void queryClient.invalidateQueries() }, + ); + + useEffect(() => { + if (!waiting) return; + const timer = setInterval(() => { + void queryClient.invalidateQueries({ queryKey: appAccessKeys.all }); + }, 2_000); + return () => clearInterval(timer); + }, [waiting]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: runs when the account appears, once per press. + useEffect(() => { + if (!pending.current || access.needsConnect) return; + pending.current = false; + stopWaiting(); + giveNow(); + }, [access.needsConnect]); + + if ( + access.needsConnect && + (access.connect === "key" || access.connect === null || tab.needsKey) + ) + return ( + + ); + + const error = give.error?.message ?? tab.error; + return ( +

+ {error ? ( +

+ {error} +

+ ) : null} + {tab.blockedUrl ? ( +

+ This browser blocked the tab.{" "} + + Open {name}'s sign-in + + . +

+ ) : waiting ? ( +

+ Finish in the tab that opened. +

+ ) : null} + +
+ ); +} + /** The person's own account: connect it, or reconnect it. */ function ConnectAction({ access }: { access: AppAccess }) { const label = access.state === "reconnect" ? "Reconnect" : "Connect"; diff --git a/app/src/lib/app-access/mutations.ts b/app/src/lib/app-access/mutations.ts index b923542bdc..af95e17105 100644 --- a/app/src/lib/app-access/mutations.ts +++ b/app/src/lib/app-access/mutations.ts @@ -111,6 +111,47 @@ export function approveBotAppMutationOptions(queryClient: QueryClient) { }); } +/** + * Give one of your own apps to a Bot you own: its read-only actions, run on whoever is asking's own + * account. A plain function as well as a mutation, because the New Bot sheet gives several in a row + * right after the Bot is made, outside any one press. + */ +export async function giveBotApp(variables: { + agentId: string; + app: string; +}): Promise<{ granted: string[] }> { + const response = await client( + `/api/plugins/for/${encodeURIComponent(variables.agentId)}/apps/${encodeURIComponent(variables.app)}/give`, + { method: "POST", fallback: "Couldn't give that app to this Bot." }, + ); + return response.json(); +} + +/** One press of "Let it use your Gmail", on the card or a Bot's page. */ +export function giveBotAppMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: giveBotApp, + onSettled: () => refresh(queryClient), + }); +} + +/** Take back what you gave a Bot you own. Anything an admin gave it stays. */ +export function takeBackBotAppMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + agentId: string; + app: string; + }): Promise<{ revoked: string[] }> => { + const response = await client( + `/api/plugins/for/${encodeURIComponent(variables.agentId)}/apps/${encodeURIComponent(variables.app)}/give`, + { method: "DELETE", fallback: "Couldn't take that app back." }, + ); + return response.json(); + }, + onSettled: () => refresh(queryClient), + }); +} + /** Dismiss a request without changing anything else. */ export function dismissAppAccessRequestMutationOptions( queryClient: QueryClient, diff --git a/app/src/lib/app-access/queries.ts b/app/src/lib/app-access/queries.ts index 2c4313f2b5..b2dcf86380 100644 --- a/app/src/lib/app-access/queries.ts +++ b/app/src/lib/app-access/queries.ts @@ -12,7 +12,7 @@ import { client } from "@/lib/client"; */ /** - * The seven things a person can be told about one app on one Bot. + * The eight things a person can be told about one app on one Bot. * * `ready` — usable now. `not_connected` / `reconnect` — waiting on an account: this person's own, * which they connect or reconnect themselves, or on somebody else's Team Bot the owner's, which only @@ -21,7 +21,9 @@ import { client } from "@/lib/client"; * connected, but an administrator has not approved this Bot's audience for it, so every call is * refused; the refused call files the administrator's request itself, so there is none to press. * `disabled` — switched off at the deployment, for the reason `unavailable` gives. `not_granted` — - * enabled, and this Bot holds none of it. + * enabled, and this Bot holds none of it; only an admin can give it. `owner_grantable` — the same, + * except the person looking owns the Bot and the app is open to owners, so they can give it + * themselves (see `needsConnect`). */ export type AppAccessState = | "ready" @@ -30,7 +32,8 @@ export type AppAccessState = | "shared_not_connected" | "shared_not_approved" | "disabled" - | "not_granted"; + | "not_granted" + | "owner_grantable"; /** * Why an app is switched off. `absent` — nobody switched it on here. `composio_unconfigured` — a @@ -77,6 +80,15 @@ export type AppAccess = { asksFirst: boolean; /** Whether this Bot holds any of the app's tools. */ granted: boolean; + /** Who may give the app to a Bot: any Bot's owner, or only an admin. */ + ownerGrants: "any" | "admin"; + /** Whether everything this Bot holds of the app was given by its owner, who may take it back. */ + givenByOwner: boolean; + /** + * In `owner_grantable`, whether giving it has to start with connecting the person's own account. + * False in every other state. + */ + needsConnect: boolean; /** * When the account this app runs in for this person was connected, or null. Read by the chat card * to tell an account lost at the vendor from one connected again since. See `AppAccessCard`. @@ -123,8 +135,30 @@ export const appAccessKeys = { ["plugins", "app-access", "bot", agentId, "app", app] as const, inbox: (app?: string) => ["plugins", "app-access", "inbox", { app: app ?? null }] as const, + giveable: ["plugins", "app-access", "giveable"] as const, }; +/** An app this person could give their own Bots. */ +export type GiveableApp = Pick< + AppAccess, + "key" | "displayName" | "logo" | "account" +>; + +/** + * The apps this person could give their own Bots: switched on and open to owners, and connected + * where the app runs on their own account. What the New Bot sheet offers ticked, and the switches + * on a Bot's page. Empty where an admin turned the feature off for them. + */ +export function giveableAppsQueryOptions() { + return queryOptions({ + queryKey: appAccessKeys.giveable, + queryFn: (): Promise => + client("/api/plugins/giveable", "apps", { + fallback: "Couldn't load your apps.", + }), + }); +} + /** Every work app this Bot holds, with what each is for the person asking. */ export function botAppsQueryOptions(agentId: string) { return queryOptions({ diff --git a/app/tests/app-access-action.test.tsx b/app/tests/app-access-action.test.tsx index 89fb1d0a41..d25d2e4693 100644 --- a/app/tests/app-access-action.test.tsx +++ b/app/tests/app-access-action.test.tsx @@ -28,7 +28,7 @@ import { settleReactWork } from "./settle-react-work"; /** * The one piece the Bot page and the chat card both draw an app's state with. * - * Each of the seven states must say whose move it is and offer exactly that move — and nothing for a + * Each of the eight states must say whose move it is and offer exactly that move — and nothing for a * state that needs none, or a move that is somebody else's: an administrator's, or on somebody else's * Team Bot its owner's. The two moves that write are asserted against the request they send: the * browser names a Bot and an app and never a kind, which is the server's to decide. @@ -531,3 +531,56 @@ test.each([ expect(link.getAttribute("target")).toBe("_blank"); }, ); + +test("an owner is asked about their own app, by the Bot's name where the caller has it", () => { + const owners = appAccess({ state: "owner_grantable", granted: false }); + expect(appAccessSentence(owners)).toBe("Let this Bot use your Gmail?"); + expect(appAccessSentence(owners, "Research Bot")).toBe( + "Let Research Bot use your Gmail?", + ); + expect( + appAccessSentence({ ...owners, needsConnect: true }, "Research Bot"), + ).toBe("Let Research Bot use your Gmail? Connect it first."); + // An app on nobody's own account is not "your" anything. + expect( + appAccessSentence( + { ...owners, displayName: "Routines", account: "deployment" }, + "Research Bot", + ), + ).toBe("Let Research Bot use Routines?"); +}); + +test("an owner's Allow gives the app in one press, naming the Bot and the app and nothing else", async () => { + const { view, sent } = mounted( + appAccess({ state: "owner_grantable", granted: false }), + ); + fireEvent.click(await view.findByRole("button", { name: /^Let .* Gmail$/ })); + await waitFor(() => + expect(sent.filter((call) => call.method === "POST")).toEqual([ + { + method: "POST", + url: "/api/plugins/for/expenses/apps/composio-gmail/give", + body: undefined, + }, + ]), + ); +}); + +test("Connect and allow opens the person's consent first, and gives the app once it is connected", async () => { + const waiting = appAccess({ + state: "owner_grantable", + granted: false, + needsConnect: true, + }); + const { view, sent, opened, swap } = mounted(waiting); + fireEvent.click( + await view.findByRole("button", { name: /after you connect it$/ }), + ); + await waitFor(() => expect(opened).toEqual(["https://gmail.test/consent"])); + const gives = () => + sent.filter((call) => call.url.endsWith("/apps/composio-gmail/give")); + expect(gives()).toEqual([]); + // The server's next answer: connected now, and still not given. + swap({ ...waiting, needsConnect: false }); + await waitFor(() => expect(gives()).toHaveLength(1)); +}); diff --git a/app/tests/app-access-fixture.ts b/app/tests/app-access-fixture.ts index 2a6a99e7e8..e21306374d 100644 --- a/app/tests/app-access-fixture.ts +++ b/app/tests/app-access-fixture.ts @@ -19,6 +19,9 @@ export function appAccess(overrides: Partial = {}): AppAccess { accountHolder: "you", asksFirst: false, granted: true, + ownerGrants: "any", + givenByOwner: false, + needsConnect: false, connectedAt: null, requestKind: null, request: null, From 597ff8b2a713e4f64f949af43ede5f2b6fdd42f1 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:35:46 -0300 Subject: [PATCH 30/73] Give a new Bot your connected apps, and switch them on its page --- app/src/components/agents/new-bot-sheet.tsx | 74 +++++++++ app/src/components/bot-profile/access.tsx | 142 +++++++++++++++- app/tests/bot-access-your-apps.test.tsx | 169 ++++++++++++++++++++ app/tests/new-bot-sheet.test.tsx | 55 +++++++ 4 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 app/tests/bot-access-your-apps.test.tsx diff --git a/app/src/components/agents/new-bot-sheet.tsx b/app/src/components/agents/new-bot-sheet.tsx index 47386372dc..87393f7930 100644 --- a/app/src/components/agents/new-bot-sheet.tsx +++ b/app/src/components/agents/new-bot-sheet.tsx @@ -10,7 +10,9 @@ import { MotionConfig, motion, useReducedMotion } from "motion/react"; import { useEffect, useRef, useState } from "react"; import { HarnessMark } from "@/components/agents/harness-mark"; import AgentOrb from "@/components/agents/orb/agent-orb"; +import { PluginLogo } from "@/components/plugins/plugin-logo"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Field, FieldDescription, @@ -39,7 +41,13 @@ import { agentCapabilitiesQueryOptions, testAgentConnection, } from "@/lib/agents/queries"; +import { giveBotApp } from "@/lib/app-access/mutations"; +import { + type GiveableApp, + giveableAppsQueryOptions, +} from "@/lib/app-access/queries"; import { isComposing } from "@/lib/composing"; +import { pluginKeys } from "@/lib/plugins/queries"; import { cn } from "@/lib/utils"; import { queryClient } from "@/query-client"; @@ -165,6 +173,13 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { const [unreachable, setUnreachable] = useState(); /** The Bot just made, held for the moment the sheet spends saying so before it hands over. */ const [createdId, setCreatedId] = useState(); + /** + * The person's own apps this Bot may use. Ticked unless the person unticks one: an app they + * connected is one they already use, and a new Bot that cannot reach any of it is no use until + * somebody finds the Bot page. Kept as what was UNticked so an app that loads late arrives ticked. + */ + const giveable = useQuery(giveableAppsQueryOptions()).data ?? []; + const [leftOut, setLeftOut] = useState>(() => new Set()); const reduceMotion = useReducedMotion(); // A deployment with no Bot of its own cannot run one here, so nothing is chosen for the person. @@ -228,6 +243,17 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { authValue: managed ? key : "", }), ); + /* + * The ticked apps, given once the Bot exists. A refusal is not a failed create: the Bot is made, + * and its page lists every app it could be given with a switch each. So each is tried, none + * holds up the others, and none stops the hand-over. + */ + await Promise.allSettled( + giveable + .filter((app) => !leftOut.has(app.key)) + .map((app) => giveBotApp({ agentId: agent.id, app: app.key })), + ); + void queryClient.invalidateQueries({ queryKey: pluginKeys.all }); setCreatedId(agent.id); }; @@ -334,8 +360,18 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { > {step === "about" ? ( + setLeftOut((current) => { + const next = new Set(current); + if (used) next.delete(app); + else next.add(app); + return next; + }) + } runtime={runtime ?? { kind: "builtin" }} onInstructions={setInstructions} onName={setName} @@ -512,8 +548,14 @@ const VISIBILITY_OPTIONS: Array<{ * Who can see it was the old wizard's last step. It is here because this step had the room, and a * question that does not change what is being made is cheaper answered alongside the name than as a * screen of its own. + * + * The apps it can use come last and arrive ticked: the person's own connected apps an admin lets + * owners give. Drawn only when there are some, so a person with none connected is asked nothing. */ function AboutStep({ + apps, + leftOut, + onApp, name, title, instructions, @@ -533,6 +575,9 @@ function AboutStep({ onTitle: (value: string) => void; onInstructions: (value: string) => void; onVisibility: (value: Visibility) => void; + apps: GiveableApp[]; + leftOut: Set; + onApp: (app: string, used: boolean) => void; }) { const runsHere = runtime.kind === "builtin"; return ( @@ -611,6 +656,35 @@ function AboutStep({ })}
+ {apps.length > 0 ? ( +
+ Apps it can use + + It always uses the account of whoever is asking. + +
+ {apps.map((app) => ( + + ))} +
+
+ ) : null} ); } diff --git a/app/src/components/bot-profile/access.tsx b/app/src/components/bot-profile/access.tsx index f6b22ef8ef..64fe2b5803 100644 --- a/app/src/components/bot-profile/access.tsx +++ b/app/src/components/bot-profile/access.tsx @@ -2,6 +2,7 @@ import { IconApps, IconBox, IconChevronRight, + IconPlug, IconPlus, } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -30,7 +31,16 @@ import { import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import type { AgentProfile } from "@/lib/agents/queries"; -import { botAppsQueryOptions } from "@/lib/app-access/queries"; +import { + giveBotAppMutationOptions, + takeBackBotAppMutationOptions, +} from "@/lib/app-access/mutations"; +import { + type AppAccess, + botAppsQueryOptions, + type GiveableApp, + giveableAppsQueryOptions, +} from "@/lib/app-access/queries"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { setPluginGrantMutationOptions } from "@/lib/plugins/mutations"; import { @@ -86,13 +96,133 @@ export function connectorCount(plugins: GrantedPlugins): number { export function AccessSections({ agent }: { agent: AgentProfile }) { return ( <> + {agent.mine ? : null} {agent.mine ? : null} - + ); } +/** + * Your own apps, one switch each: the ones you connected that an admin lets owners give, and any you + * already gave this Bot. Only on your own Bot, for the reason the server gives — see + * `server/src/plugins/owner-grants.ts`. + * + * WHAT A SWITCH GIVES is what an admin's one press gives: the app's read-only actions. Every call + * runs on the asking person's own account, even after the Bot is published to the team, so the + * description says "your" only about the person reading it. + * + * AN APP AN ADMIN GAVE THIS BOT is drawn switched on and locked. Taking it away is the admin's, and + * the server would refuse it. + */ +function YourApps({ agent }: { agent: AgentProfile }) { + const giveable = useQuery(giveableAppsQueryOptions()); + const held = useQuery(botAppsQueryOptions(agent.id)); + const give = useMutation(giveBotAppMutationOptions(queryClient)); + const takeBack = useMutation(takeBackBotAppMutationOptions(queryClient)); + const error = give.error ?? takeBack.error; + const busy = give.isPending || takeBack.isPending; + if (giveable.isPending || held.isPending) return null; + const states = new Map((held.data ?? []).map((app) => [app.key, app])); + /* + * What you could give, and then anything you already gave that no longer is — an account you + * disconnected since, an app an admin closed to owners — so it can still be switched off here. + */ + const rows: (GiveableApp & { access?: AppAccess })[] = [ + ...(giveable.data ?? []).map((app) => ({ + ...app, + access: states.get(app.key), + })), + ...(held.data ?? []) + .filter( + (app) => + app.givenByOwner && + !(giveable.data ?? []).some((given) => given.key === app.key), + ) + .map((app) => ({ ...app, access: app })), + ]; + return ( + + {error ? ( +

+ {error.message} +

+ ) : null} + {giveable.error ? ( +

+ Couldn't load your apps. Reload the page. +

+ ) : rows.length === 0 ? ( + } + size="sm" + variant="outline" + > + + Connections + + } + icon={IconApps} + title="No apps to give yet" + > + Connect an app to let your Bots use it. + + ) : ( + + {rows.map((app, index) => { + const granted = app.access?.granted ?? false; + const locked = granted && !app.access?.givenByOwner; + const yours = + app.account === "personal" + ? `your ${app.displayName}` + : app.displayName; + return ( + + {index > 0 ? : null} + + + + + + {app.displayName} + + {locked + ? "An admin gave it to this Bot." + : granted + ? `${agent.name} can use ${yours}.` + : `${agent.name} can't use ${yours}.`} + + + + { + give.reset(); + takeBack.reset(); + const variables = { agentId: agent.id, app: app.key }; + if (next) give.mutate(variables); + else takeBack.mutate(variables); + }} + /> + + + + ); + })} + + )} +
+ ); +} + /** * Your own skills, one switch each. Only on your own Bot: the server lets a person put their own * skill on a Bot they own, and neither half alone is enough. @@ -177,7 +307,7 @@ function YourSkills({ agentId }: { agentId: string }) { * the person looking. * * READ-ONLY FOR GRANTS, NOT FOR THE PERSON'S OWN PART. Apps are granted by an administrator, who gets - * a link to where that is decided. But a Bot that holds Gmail is no use to somebody who has not + * a link to where that is decided — or, on a Bot you own, by you, under Your apps above. But a Bot that holds Gmail is no use to somebody who has not * connected their Gmail, and this list used to say "Gmail · 4 tools" to everybody alike — so each * app now says what it is for the person reading (`GET /api/plugins/for/:agentId/apps`) with the one * thing they can do about it: Connect, Reconnect, Request access, or Requested. The state is the @@ -187,7 +317,7 @@ function YourSkills({ agentId }: { agentId: string }) { * whoever is asking, exactly as the connections read does, so this cannot become a list of who has * connected what. */ -function Granted({ agentId }: { agentId: string }) { +function Granted({ agentId, mine }: { agentId: string; mine: boolean }) { const me = useQuery(currentUserQueryOptions()).data; const plugins = useQuery(agentPluginsQueryOptions(agentId)); const apps = useQuery(botAppsQueryOptions(agentId)); @@ -224,7 +354,9 @@ function Granted({ agentId }: { agentId: string }) { ) : null} {count === 0 ? ( - An admin has to add them. Until then this Bot can only talk with you. + {mine + ? "Turn one on under Your apps, or ask an admin. Until then this Bot can only talk with you." + : "An admin has to add them. Until then this Bot can only talk with you."} ) : ( diff --git a/app/tests/bot-access-your-apps.test.tsx b/app/tests/bot-access-your-apps.test.tsx new file mode 100644 index 0000000000..1601fff7c0 --- /dev/null +++ b/app/tests/bot-access-your-apps.test.tsx @@ -0,0 +1,169 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { AccessSections } from "@/components/bot-profile/access"; +import type { AgentProfile } from "@/lib/agents/queries"; +import type { AppAccess } from "@/lib/app-access/queries"; +import { appAccess } from "./app-access-fixture"; +import { settleReactWork } from "./settle-react-work"; + +/** + * Your apps, on a Bot you own: one switch per app you connected that an admin lets owners give. + * Switching one on gives it; switching it off takes back what you gave; an app an admin gave stays + * on and cannot be switched by you. Not drawn at all on somebody else's Bot. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +const BOT: AgentProfile = { + id: "expenses", + name: "Expenses", + title: "Finance", + roleDescription: "Receipts.", + avatarSeed: "e", + visibility: "private", + endpoint: null, + builtIn: true, + hasAuth: false, + hasCallbackToken: false, + hidden: false, + pinned: false, + systemOwned: false, + canManage: true, + mine: true, +}; + +const GMAIL = { + key: "composio-gmail", + displayName: "Gmail", + logo: null, + account: "personal", +}; +const NOTION = { + key: "notion", + displayName: "Notion", + logo: null, + account: "personal", +}; + +function serving(held: AppAccess[]) { + const writes: string[] = []; + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + if (url.endsWith("/give")) { + writes.push(`${method} ${url}`); + return Response.json({ granted: [], revoked: [] }); + } + if (url === "/api/plugins/giveable") + return Response.json({ apps: [GMAIL, NOTION] }); + if (url.includes("/api/plugins/for/") && url.endsWith("/apps")) + return Response.json({ apps: held }); + if (url.includes("/api/plugins/for/")) + return Response.json({ tools: [], skills: [] }); + if (url.endsWith("/api/plugins")) + return Response.json({ + catalogue: [], + servers: [], + botsMayCallBack: true, + redirectUri: null, + skills: [], + }); + if (url.includes("/handoff")) + return Response.json({ + handoff: { + enabled: true, + canGrant: false, + reachable: [], + grantable: true, + }, + }); + if (url.includes("/api/agents")) return Response.json({ agents: [] }); + return Response.json({ + user: { id: "me", role: "user", email: "me@example.test", name: "Me" }, + }); + }) as unknown as typeof fetch; + return writes; +} + +function draw(agent: AgentProfile) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: createRootRoute({ + component: () => , + }), + }); + return render( + + + , + ); +} + +test("each app you could give has a switch, on where you gave it, and pressing it gives or takes back", async () => { + const writes = serving([ + appAccess({ key: "notion", displayName: "Notion", givenByOwner: true }), + ]); + const view = draw(BOT); + const gmail = await view.findByRole("switch", { + name: "Let Expenses use Gmail", + }); + const notion = view.getByRole("switch", { name: "Let Expenses use Notion" }); + expect(gmail.getAttribute("aria-checked")).toBe("false"); + expect(notion.getAttribute("aria-checked")).toBe("true"); + expect(view.getByText("Expenses can't use your Gmail.")).toBeTruthy(); + + fireEvent.click(gmail); + await waitFor(() => + expect(writes).toContain( + "POST /api/plugins/for/expenses/apps/composio-gmail/give", + ), + ); + fireEvent.click(notion); + await waitFor(() => + expect(writes).toContain( + "DELETE /api/plugins/for/expenses/apps/notion/give", + ), + ); +}); + +test("an app an admin gave this Bot is on, and not yours to switch off", async () => { + serving([appAccess({ givenByOwner: false })]); + const view = draw(BOT); + const gmail = await view.findByRole("switch", { + name: "Let Expenses use Gmail", + }); + expect(gmail.getAttribute("aria-checked")).toBe("true"); + expect( + gmail.hasAttribute("disabled") || + gmail.getAttribute("aria-disabled") === "true", + ).toBe(true); + expect(view.getByText("An admin gave it to this Bot.")).toBeTruthy(); +}); + +test("somebody else's Bot has no Your apps section", async () => { + serving([]); + const view = draw({ ...BOT, mine: false }); + await view.findByText("What it can use"); + expect(view.queryByText("Your apps")).toBeNull(); +}); diff --git a/app/tests/new-bot-sheet.test.tsx b/app/tests/new-bot-sheet.test.tsx index 7249b34cb0..4547f77f53 100644 --- a/app/tests/new-bot-sheet.test.tsx +++ b/app/tests/new-bot-sheet.test.tsx @@ -57,12 +57,22 @@ let verdict: { ok: true; events: string[] } | { ok: false; reason: string }; let creates: Record[] = []; /** Every address the server was asked to reach. */ let dialled: string[] = []; +/** The person's own apps the server offers to give a new Bot, and every give it was sent. */ +let giveable: { + key: string; + displayName: string; + logo: null; + account: string; +}[] = []; +let given: string[] = []; beforeEach(() => { builtInAvailable = true; verdict = { ok: true, events: ["RUN_STARTED"] }; creates = []; dialled = []; + giveable = []; + given = []; global.fetch = Object.assign( async (path: Parameters[0], init?: RequestInit) => { const body = init?.body ? JSON.parse(String(init.body)) : undefined; @@ -77,6 +87,12 @@ beforeEach(() => { creates.push(body); return Response.json({ agent: { id: "agent_new" } }, { status: 201 }); } + if (path === "/api/plugins/giveable") + return Response.json({ apps: giveable }); + if (String(path).endsWith("/give") && init?.method === "POST") { + given.push(String(path)); + return Response.json({ granted: [] }); + } return new Response(null, { status: 404 }); }, { preconnect: originalFetch.preconnect }, @@ -289,3 +305,42 @@ test("going back keeps what was answered, and Run it here leaves a typed address expect(creates[0]).toMatchObject({ endpoint: "", visibility: "public" }); expect(creates[0]).not.toHaveProperty("auth"); }); + +test("the person's own apps arrive ticked, and the ones left ticked are given once the Bot exists", async () => { + giveable = [ + { + key: "composio-gmail", + displayName: "Gmail", + logo: null, + account: "personal", + }, + { + key: "routines", + displayName: "Routines", + logo: null, + account: "deployment", + }, + ]; + const { view, user } = await opened(); + await user.click(view.getByRole("button", { name: "Continue" })); + await user.type(await view.findByLabelText("Name"), "Research"); + const gmail = await view.findByRole("checkbox", { name: "Gmail" }); + const routines = view.getByRole("checkbox", { name: "Routines" }); + expect(gmail.getAttribute("aria-checked")).toBe("true"); + expect(routines.getAttribute("aria-checked")).toBe("true"); + await user.click(routines); + expect(routines.getAttribute("aria-checked")).toBe("false"); + await user.click(view.getByRole("button", { name: "Create" })); + expect(await view.findByText("Research is ready")).toBeTruthy(); + expect(given).toEqual([ + "/api/plugins/for/agent_new/apps/composio-gmail/give", + ]); +}); + +test("a person with no apps to give is asked nothing about apps", async () => { + const { view, user } = await opened(); + await user.click(view.getByRole("button", { name: "Continue" })); + await view.findByLabelText("Name"); + await settle(); + expect(view.queryByText("Apps it can use")).toBeNull(); +}); From dd55aaeac40ca3f6595720080fc1a0e057e2ce99 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:57:07 -0300 Subject: [PATCH 31/73] Keep a chart's sources line readable when the model's arguments are odd Separate the unchecked note from the sources list, name a repeated source once, and skip sources or dates that are not the shape the schema asks for instead of breaking the card. --- app/src/components/gallery/charts.tsx | 32 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/app/src/components/gallery/charts.tsx b/app/src/components/gallery/charts.tsx index 4315cd9feb..c1d815f9ca 100644 --- a/app/src/components/gallery/charts.tsx +++ b/app/src/components/gallery/charts.tsx @@ -61,14 +61,35 @@ type Provenance = Partial<{ }>; /** Only web addresses become links. Anything else the model wrote is shown as text. */ -function isWebAddress(url: string | undefined): url is string { - return !!url && /^https?:\/\//i.test(url); +function isWebAddress(url: unknown): url is string { + return typeof url === "string" && /^https?:\/\//i.test(url); +} + +/** + * The sources worth naming, once each. The arguments are drawn as the model wrote them, unchecked, + * so a source that is not a titled object is skipped instead of breaking the card. + */ +function namedSources(sources: unknown): { title: string; url?: string }[] { + if (!Array.isArray(sources)) return []; + const seen = new Set(); + const named: { title: string; url?: string }[] = []; + for (const entry of sources) { + const title = typeof entry?.title === "string" ? entry.title.trim() : ""; + if (!title) continue; + const url = isWebAddress(entry.url) ? entry.url : undefined; + const key = `${title}|${url ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + named.push(url ? { title, url } : { title }); + } + return named; } /** The line under a chart that says where its figures came from. Nothing when nobody said. */ -function Sources({ basis, sources, asOf }: Provenance) { - const named = (sources ?? []).filter((entry) => entry?.title?.trim()); +function Sources({ basis, sources, asOf: when }: Provenance) { + const named = namedSources(sources); const unchecked = basis === "own-knowledge"; + const asOf = typeof when === "string" ? when.trim() : ""; if (!unchecked && named.length === 0 && !asOf) return null; return (

@@ -79,11 +100,12 @@ function Sources({ basis, sources, asOf }: Provenance) { ) : null} {named.length > 0 ? ( <> + {unchecked ? " · " : null} {"Sources: "} {named.map((entry, index) => ( {index > 0 ? ", " : null} - {isWebAddress(entry.url) ? ( + {entry.url ? ( Date: Mon, 26 Oct 2026 12:57:07 -0300 Subject: [PATCH 32/73] Test the sources line with an unchecked note and malformed arguments --- app/tests/chart-sources.test.tsx | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/app/tests/chart-sources.test.tsx b/app/tests/chart-sources.test.tsx index 3c38483ef3..f1675775d3 100644 --- a/app/tests/chart-sources.test.tsx +++ b/app/tests/chart-sources.test.tsx @@ -56,6 +56,37 @@ test("figures from the Bot's own knowledge are marked as unchecked", () => { expect(view.getByText(/Not checked against a source/)).toBeTruthy(); }); +test("the unchecked note and the sources read as separate parts", () => { + const view = render( + , + ); + expect(view.container.textContent).toContain( + "Not checked against a source. · Sources: Annual report", + ); +}); + +test("sources the model wrote in the wrong shape are skipped, not fatal", () => { + const view = render( + [0])} + />, + ); + expect(view.container.textContent).toContain("Sources: Notes"); + expect(view.container.textContent).not.toContain("Notes, Notes"); + expect(view.queryByText(/As of/)).toBeNull(); +}); + test("only web addresses become links", () => { const view = render( Date: Mon, 26 Oct 2026 12:57:25 -0300 Subject: [PATCH 33/73] Count a routine firing as the conversation moving on past an app card The card that carries a task on once its app is ready only did so while it was the latest thing, but a routine firing after it did not count, so a later connect sent "please carry on" onto the routine's work. Any user turn now counts. The rule is pulled out as latestFrom and tested. --- .../components/channels/chat-transcript.tsx | 42 +++++++---- app/tests/transcript-latest.test.ts | 75 +++++++++++++++++++ 2 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 app/tests/transcript-latest.test.ts diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 3b14c80e26..921a8b1738 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -1454,6 +1454,32 @@ export function isPersonSentMessage( return role === "user" && readFiring(text) === null; } +/** + * WHERE THE CONVERSATION HAS GOT TO, for an app card that may carry its task on once the app is + * ready (see `lib/copilot/carry-on.ts`): the index from which a row is still the latest thing, or + * past the end when none is. + * + * A card is the latest thing while nothing has been said to the Bot since it, nothing is queued, and + * the Bot is not working: past that, the conversation has moved on, and an older task is not picked + * up again underneath it. ANY USER TURN COUNTS, a routine firing included — unlike the scroll + * machinery's `isPersonSentMessage`. A firing is not the person speaking, but it is a new task the + * Bot has answered, and "please carry on" sent after it would land on the routine's work rather than + * on the task the card interrupted. Exported so it can be checked without mounting anything. + */ +export function latestFrom( + items: readonly TranscriptItem[], + { busy, queued }: { busy: boolean; queued: number }, +): number { + if (busy || queued > 0) return items.length; + return ( + items.findLastIndex( + (item) => + (item.kind === "text" && item.role === "user") || + item.kind === "attachments", + ) + 1 + ); +} + /** * The turn a row belongs to. * @@ -1615,18 +1641,8 @@ export function ChatTranscript({ )?.id ?? null; useSmoothSendScroll(viewportRef, newestUserMessageId); - /* - * WHERE THE CONVERSATION HAS GOT TO, for an app card that may carry its task on once the app is - * ready (see `lib/copilot/carry-on.ts`). A card is the latest thing while the person has said - * nothing since it, has nothing queued, and the Bot is not working: past that, the conversation - * has moved on, and an older task is not picked up again underneath it. - */ - const lastSaid = items.findLastIndex( - (item) => - (item.kind === "text" && isPersonSentMessage(item.role, item.text)) || - item.kind === "attachments", - ); - const settled = !busy && queued.length === 0; + /* Which tool calls are still the latest thing, for an app card: see `latestFrom`. */ + const latestIndex = latestFrom(items, { busy, queued: queued.length }); /* One rule for both kinds of row — see `anchorRowIds`, which is where it is written down. */ const anchorRows = anchorRowIds(items); @@ -1749,7 +1765,7 @@ export function ChatTranscript({ lastSaid} + latest={index >= latestIndex} name={item.toolCall.function.name} result={item.result} toolCallId={item.toolCall.id} diff --git a/app/tests/transcript-latest.test.ts b/app/tests/transcript-latest.test.ts new file mode 100644 index 0000000000..6718bc6bcc --- /dev/null +++ b/app/tests/transcript-latest.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { frameFiring } from "@openbot/shared/routine-firing"; +import type { VisibleChatItem } from "../src/components/channels/chat-messages"; +import { latestFrom } from "../src/components/channels/chat-transcript"; + +/** + * Which rows of a transcript are still the latest thing, the gate an app card passes before it + * carries its task on by itself (see `lib/copilot/carry-on.ts`). A row is latest only while nothing + * has been said to the Bot since it, nothing is queued, and the Bot is not working. + */ + +const said = (id: string, text = "find my invoices"): VisibleChatItem => ({ + kind: "text", + id, + role: "user", + text, +}); +const answered = (id: string): VisibleChatItem => ({ + kind: "text", + id, + role: "assistant", + text: "Gmail isn't connected yet.", +}); +const card = (id: string): VisibleChatItem => ({ + kind: "tool", + id, + toolCall: { + id, + type: "function", + function: { name: "show_app_access", arguments: "{}" }, + }, + result: "{}", +}); +const idle = { busy: false, queued: 0 }; + +describe("latestFrom", () => { + test("a card and the Bot's words after it are latest once the Bot is idle", () => { + const items = [said("u1"), card("c1"), answered("a1")]; + expect(latestFrom(items, idle)).toBe(1); + }); + + test("a card the person has spoken after is not", () => { + const items = [said("u1"), card("c1"), answered("a1"), said("u2")]; + expect(latestFrom(items, idle)).toBe(items.length); + }); + + test("a file sent with no caption moves the conversation on too", () => { + const items: VisibleChatItem[] = [ + said("u1"), + card("c1"), + { kind: "attachments", id: "u2:attachments", attachments: [] }, + ]; + expect(latestFrom(items, idle)).toBe(items.length); + }); + + test("a routine firing after the card moves it on, though nobody typed it", () => { + const items = [ + said("u1"), + card("c1"), + said("f1", frameFiring("Summarise the support queue.")), + answered("a2"), + ]; + expect(latestFrom(items, idle)).toBe(3); + }); + + test("nothing is latest while the Bot works or a message waits", () => { + const items = [said("u1"), card("c1")]; + expect(latestFrom(items, { busy: true, queued: 0 })).toBe(items.length); + expect(latestFrom(items, { busy: false, queued: 1 })).toBe(items.length); + }); + + test("a conversation the Bot started has every row latest", () => { + expect(latestFrom([card("c1")], idle)).toBe(0); + }); +}); From f3367fdcb98030712d53fd2f2ff8cbdc4749a908 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:57:51 -0300 Subject: [PATCH 34/73] Write the withdrawal into the trail when an admin's one press is undone A one press that could not be carried out withdrew the request it had just filed without a trail row, leaving an app_access.requested nothing answered. It now writes app_access.cancelled, marked direct, as a withdrawal does. --- server/src/plugins/route-groups/app-access.ts | 30 +++++++++++++++---- .../app-access-routes.integration.test.ts | 11 +++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts index bb99c6e35c..301ac2b002 100644 --- a/server/src/plugins/route-groups/app-access.ts +++ b/server/src/plugins/route-groups/app-access.ts @@ -568,12 +568,30 @@ export function mountAppAccessRoutes({ } finally { if (!done.carriedOut) { await store.releaseAppAccessRequest({ id: claimed.id, by }); - if (filed.created) - await store.closeAppAccessRequest({ - id: claimed.id, - status: "cancelled", - by, - requestedBy: by, + /* + * Withdrawn with the row a withdrawal always writes, so the trail does not hold an + * `app_access.requested` that nothing ever answered. + */ + const withdrawn = filed.created + ? await store.closeAppAccessRequest({ + id: claimed.id, + status: "cancelled", + by, + requestedBy: by, + }) + : null; + if (withdrawn) + await store.recordAppAccessEvent({ + eventType: "app_access.cancelled", + targetId: withdrawn.appKey, + actorUserId: by, + payload: { + request: withdrawn.id, + app: withdrawn.appKey, + bot: withdrawn.agentId, + kind: withdrawn.kind, + direct: true, + }, }); } } diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts index 7870c44db7..a32c4a132d 100644 --- a/server/tests/app-access-routes.integration.test.ts +++ b/server/tests/app-access-routes.integration.test.ts @@ -1084,6 +1084,16 @@ describe("an administrator's one press on a card", () => { .from(appAccessRequests) .where(eq(appAccessRequests.requestedBy, admin)); expect(rows.map((row) => row.status)).toEqual(["cancelled"]); + // The trail closes what it opened: filed, refused, then withdrawn. + const filed = eventsOf("app_access.requested")[0]?.payload as { + request?: string; + }; + expect(eventsOf("app_access.cancelled")[0]?.payload).toMatchObject({ + request: filed.request, + app: "google-drive", + direct: true, + }); + expect(eventsOf("app_access.approved")).toHaveLength(0); }); test("a request of theirs already waiting is put back as it was", async () => { @@ -1097,6 +1107,7 @@ describe("an administrator's one press on a card", () => { .from(appAccessRequests) .where(eq(appAccessRequests.requestedBy, admin)); expect(rows.map((row) => row.status)).toEqual(["pending"]); + expect(eventsOf("app_access.cancelled")).toHaveLength(0); }); }); From a55db65e9c9a7a4a9b3c9610ebb9e1c9706497df Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:57:54 -0300 Subject: [PATCH 35/73] Word the no-search guidance as a condition, not a claim that the Bot holds no search tool Search tools the catalogue roles do not recognise (a Composio toolkit, a server added by URL, Exa's advanced search granted alone) would otherwise be contradicted by the prompt. --- shared/bot-prompt.test.ts | 4 ++++ shared/bot-prompt.ts | 20 +++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/shared/bot-prompt.test.ts b/shared/bot-prompt.test.ts index 2a784b3a8a..cb2b0b271b 100644 --- a/shared/bot-prompt.test.ts +++ b/shared/bot-prompt.test.ts @@ -80,6 +80,10 @@ describe("computerGuidance", () => { for (const engine of ["google", "bing", "duckduckgo"]) expect(guidance).toContain(engine); expect(guidance).not.toContain("use your search tool"); + // Conditional, not a claim: a search tool from Composio or a server added by URL is not + // recognised by role, and a Bot holding one must not be told it holds none. + expect(guidance).toContain("none of your tools searches the web"); + expect(guidance).not.toContain("you do not hold a search tool"); }); test("without a search tool, it answers marked as unchecked and offers a search app", () => { diff --git a/shared/bot-prompt.ts b/shared/bot-prompt.ts index c06618529a..7b08d31eb2 100644 --- a/shared/bot-prompt.ts +++ b/shared/bot-prompt.ts @@ -39,6 +39,12 @@ const OPENING_A_PAGE_LINES = [ * search app with the card tool, because "I cannot search" with no way to fix it is the same dead end * a missing app used to be. The names are the catalogue's search apps, since the card resolves an * app by name and there is no app called "search"; see `RESEARCH_APP_NAMES`. + * + * Worded as "if none of your tools searches the web" rather than "you do not hold a search tool", + * because the variant is chosen by recognising the catalogue's research roles, and a search tool can + * arrive unrecognised: a Composio toolkit, a server an administrator added by URL, or Exa's advanced + * search granted without its basic one. Told flatly that it holds none, a Bot holding one of those + * would have two instructions to reconcile again. */ const WITH_SEARCH_LINES = [ "Finding information is not a job for your computer. When you are asked to search for something,", @@ -49,18 +55,18 @@ const WITH_SEARCH_LINES = [ ]; const WITHOUT_SEARCH_LINES = [ - "Opening a page is not a web search, and you do not hold a search tool. NEVER drive your browser", - "to a search engine (Google, Bing, DuckDuckGo or any other) to look something up, and do not guess", - "at addresses hoping one of them has the answer.", + "Opening a page is not a web search. NEVER drive your browser to a search engine (Google, Bing,", + "DuckDuckGo or any other) to look something up, and do not guess at addresses hoping one of them", + "has the answer.", "When you are asked to search for something, look something up, find the latest on a subject or", - "find a few examples of something, answer from what you already know and say plainly that you have", - "not checked it against a source.", + "find a few examples of something, and none of your tools searches the web, answer from what you", + "already know and say plainly that you have not checked it against a source.", `If you hold ${SHOW_APP_ACCESS_TOOL}, call it once in the conversation with the name of one search`, `app (${RESEARCH_APP_NAMES.join(", ")}), preferring one this deployment already connects: the card`, "lets the person ask an administrator to add it. Otherwise say an administrator can add a search", "app.", - "Asked whether you can search the web, say you can open and read pages the person names but cannot", - "search.", + "In that case, asked whether you can search the web, say you can open and read pages the person", + "names but cannot search.", ]; /** From a370171cfb05a0a1a71fc1aef986c694a6dcbf4d Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:57:54 -0300 Subject: [PATCH 36/73] Name computerGuidance, not the removed COMPUTER_GUIDANCE, in a copilot test comment --- server/tests/copilot.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts index 6e3c266c98..613e5fa524 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -990,7 +990,7 @@ function fakeAgUiEndpoint() { * A Bot is told what it holds, not only handed it. * * A tool array tells a model a tool exists. It does not say the tool is the right way to reach that - * system, and it competes with `COMPUTER_GUIDANCE`: a page of emphatic prose about the browser that + * system, and it competes with `computerGuidance`: a page of emphatic prose about the browser that * every Bot gets whether or not it has a single connector, and that mentions connectors nowhere. * * The browser prose won. A Bot holding four Google Drive tools browsed to drive.google.com, met a From cf88214dfa764c5ed49a3edd93764d8272bd2a91 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:58:28 -0300 Subject: [PATCH 37/73] Tell an admin's one press to try again, not to approve a request The one press withdraws the request it filed when it cannot be carried out, yet its refusals ended "then approve this request again". They now end "then try again" there; the inbox's wording is unchanged. The browser's fallback sentence follows the copy rules too. --- app/src/lib/app-access/mutations.ts | 2 +- server/src/plugins/route-groups/app-access.ts | 32 +++++++++++++------ .../app-access-routes.integration.test.ts | 7 ++-- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/app/src/lib/app-access/mutations.ts b/app/src/lib/app-access/mutations.ts index b923542bdc..f550bbd847 100644 --- a/app/src/lib/app-access/mutations.ts +++ b/app/src/lib/app-access/mutations.ts @@ -103,7 +103,7 @@ export function approveBotAppMutationOptions(queryClient: QueryClient) { }> => { const response = await client( `/api/plugins/for/${encodeURIComponent(variables.agentId)}/apps/${encodeURIComponent(variables.app)}/approve`, - { method: "POST", fallback: "That app could not be turned on." }, + { method: "POST", fallback: "Couldn't turn that app on. Try again." }, ); return response.json(); }, diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts index 301ac2b002..bc6a782b9d 100644 --- a/server/src/plugins/route-groups/app-access.ts +++ b/server/src/plugins/route-groups/app-access.ts @@ -68,6 +68,7 @@ function unavailableSentence( reason: Unavailable, title: string, provider: BrokerProvider, + again = "approve this request again", ): string { switch (reason) { case "absent": @@ -75,14 +76,14 @@ function unavailableSentence( case "composio_unconfigured": { const { vendor, envVar } = BROKERED_PROFILE[provider]; const article = /^[aeiou]/i.test(vendor) ? "an" : "a"; - return `${title} is ${article} ${vendor} app, and ${envVar} isn't set. Set it, then approve this request again.`; + return `${title} is ${article} ${vendor} app, and ${envVar} isn't set. Set it, then ${again}.`; } case "scheme_unreadable": - return `${title} is turned on, but OpenBot can't read how it connects. Remove it and turn it on again under Plugins, then approve this request again.`; + return `${title} is turned on, but OpenBot can't read how it connects. Remove it and turn it on again under Plugins, then ${again}.`; case "oauth_client_missing": - return `${title} has no OAuth client yet. Register one on its page under Plugins, then approve this request again.`; + return `${title} has no OAuth client yet. Register one on its page under Plugins, then ${again}.`; case "token_missing": - return `${title} has no token yet. Add one on its page under Plugins, then approve this request again.`; + return `${title} has no token yet. Add one on its page under Plugins, then ${again}.`; } } @@ -197,6 +198,9 @@ export function mountAppAccessRoutes({ directory, }); + // The one press filed no request the admin could approve again: they press again instead. + const again = direct ? "try again" : "approve this request again"; + let inspected = await inspect(); if (!inspected) return deny( @@ -211,14 +215,24 @@ export function mountAppAccessRoutes({ if (inspected.unavailable !== "absent") return deny( inspected.unavailable, - unavailableSentence(inspected.unavailable, title, activeProvider), + unavailableSentence( + inspected.unavailable, + title, + activeProvider, + again, + ), inspected.unavailable === "composio_unconfigured" ? 503 : 409, ); if (inspected.toolkit) { if (!composio) return deny( "composio_unconfigured", - unavailableSentence("composio_unconfigured", title, activeProvider), + unavailableSentence( + "composio_unconfigured", + title, + activeProvider, + again, + ), 503, ); const outcome = await enableComposioApp( @@ -233,7 +247,7 @@ export function mountAppAccessRoutes({ if (needed) return deny( "needs_admin_input", - `${title} needs ${needed}. Add it on its page under Plugins, then approve this request again.`, + `${title} needs ${needed}. Add it on its page under Plugins, then ${again}.`, 409, ); try { @@ -293,13 +307,13 @@ export function mountAppAccessRoutes({ if (advertised.length === 0) return deny( "no_tools", - `${name} has no tools to give yet. Refresh its tools on its page under Plugins, then approve this request again.`, + `${name} has no tools to give yet. Refresh its tools on its page under Plugins, then ${again}.`, 409, ); if (refs.length === 0) return deny( "no_read_only_tools", - `None of ${name}'s actions are marked read-only, so approving here gives this Bot nothing. Choose its actions on ${name}'s page under Plugins, then approve this request again.`, + `None of ${name}'s actions are marked read-only, so approving here gives this Bot nothing. Choose its actions on ${name}'s page under Plugins, then ${again}.`, 409, ); for (const ref of refs) { diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts index a32c4a132d..5dd01e0e01 100644 --- a/server/tests/app-access-routes.integration.test.ts +++ b/server/tests/app-access-routes.integration.test.ts @@ -1076,9 +1076,10 @@ describe("an administrator's one press on a card", () => { test("a press that cannot be carried out leaves no request waiting", async () => { const response = await press("google-drive"); expect(response.status).toBe(409); - expect((await json<{ error: string }>(response)).error).toContain( - "OAuth client", - ); + const { error } = await json<{ error: string }>(response); + expect(error).toContain("OAuth client"); + // There is no request left to approve again: the admin presses again. + expect(error).toEndWith("then try again."); const rows = await database .select({ status: appAccessRequests.status }) .from(appAccessRequests) From a40eda6221d5eac24096d74e3efeebfddc353015 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:58:52 -0300 Subject: [PATCH 38/73] Refuse an owner a grant on an action their app does not advertise --- server/src/plugins/route-groups/grants.ts | 18 +++++++++++++- .../owner-app-grants.integration.test.ts | 24 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/route-groups/grants.ts b/server/src/plugins/route-groups/grants.ts index 61ebc11e98..51909e15c9 100644 --- a/server/src/plugins/route-groups/grants.ts +++ b/server/src/plugins/route-groups/grants.ts @@ -113,7 +113,23 @@ export function mountGrantRoutes({ : null; if (!inspected?.rowIds.includes(serverId ?? "")) return `${serverId} hasn't been added yet. Only an admin can add it.`; - return ownerGrantRefusal(store, actorId, agentId, inspected); + const refusal = await ownerGrantRefusal( + store, + actorId, + agentId, + inspected, + ); + if (refusal) return refusal; + /* + * AN ACTION THE APP ADVERTISES NOW, NOT ANY WORD AFTER THE SLASH. An administrator may grant a + * tool a server has stopped listing, to restore it (see `enablementRefusal`). An owner may not: + * a ref naming nothing would sit there until the vendor shipped an action by that name, and the + * Bot would then hold it with nobody having chosen it — and a bare server ref would mark the app + * given without giving it a single action. + */ + if (!(await store.appTools(serverId ?? "")).some((tool) => tool.ref === ref)) + return `${inspected.access.displayName} doesn't have that action.`; + return null; } /** diff --git a/server/tests/owner-app-grants.integration.test.ts b/server/tests/owner-app-grants.integration.test.ts index b1319c9455..034ffcb45c 100644 --- a/server/tests/owner-app-grants.integration.test.ts +++ b/server/tests/owner-app-grants.integration.test.ts @@ -371,6 +371,30 @@ describe("an owner giving their own app to their own Bot", () => { ]); }); + test("the grant route refuses an owner a ref the app does not advertise, and records nothing over an admin's grant", async () => { + const bot = await botOf(owner); + for (const ref of [`${GMAIL}/LATER`, GMAIL]) { + const refused = await appAs(owner).request( + "/api/plugins/grants", + send("POST", { kind: "mcp", ref, agentId: bot }), + ); + expect(refused.status).toBe(403); + } + expect(await grantsOn(bot)).toEqual([]); + + await store.grant("mcp", `${GMAIL}/FETCH`, bot, "admin@example.test"); + events.length = 0; + const again = await appAs(owner).request( + "/api/plugins/grants", + send("POST", { kind: "mcp", ref: `${GMAIL}/FETCH`, agentId: bot }), + ); + expect(again.status).toBe(200); + expect(await grantsOn(bot)).toEqual([ + { ref: `${GMAIL}/FETCH`, grantedAs: "admin" }, + ]); + expect(events).toEqual([]); + }); + test("the apps a person could give are the ones they connected, and open to owners", async () => { await seedApp(SLACK, "Slack", "shared"); const listed = async () => From 0e83dd3c49089d8f1f3566e71cf66bf1050f9b90 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:58:52 -0300 Subject: [PATCH 39/73] Record nothing when an owner's grant lands on one that already stands --- server/src/plugins/plugin-store/grants.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/plugin-store/grants.ts b/server/src/plugins/plugin-store/grants.ts index bb6b285a3a..d49612efe3 100644 --- a/server/src/plugins/plugin-store/grants.ts +++ b/server/src/plugins/plugin-store/grants.ts @@ -32,8 +32,17 @@ export function grantMethods(context: StoreContext) { const insert = database .insert(pluginGrants) .values({ kind, ref, agentId, grantedBy: by, grantedAs: as }); - if (as === "owner") await insert.onConflictDoNothing(); - else + if (as === "owner") { + /* + * Nothing written, nothing in the trail. A row already there is an administrator's grant + * or the owner's own from before, and a `plugin_granted` row marked `owner` over an + * administrator's grant would tell a reader the owner had given what an administrator did. + */ + const written = await insert + .onConflictDoNothing() + .returning({ ref: pluginGrants.ref }); + if (written.length === 0) return; + } else await insert.onConflictDoUpdate({ target: [pluginGrants.kind, pluginGrants.ref, pluginGrants.agentId], set: { grantedBy: by, grantedAs: "admin", updatedAt: new Date() }, From d864e92487fbd6126e5c47fbffc3669233756fbe Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:58:52 -0300 Subject: [PATCH 40/73] Say where an owner gives a write action, in the give route's note --- server/src/plugins/route-groups/app-access.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts index f957345155..07338c93a0 100644 --- a/server/src/plugins/route-groups/app-access.ts +++ b/server/src/plugins/route-groups/app-access.ts @@ -626,8 +626,9 @@ export function mountAppAccessRoutes({ * * WHAT IS GIVEN IS WHAT AN ADMINISTRATOR'S ONE PRESS GIVES: the app's read-only actions, through * the same `appTools` reading, so an owner never gets more by asking themselves than an - * administrator's approval would have given. Anything that sends or writes is still chosen action by - * action by an administrator. ONE EXCEPTION, the built-in Routines: every action it has keeps, + * administrator's approval would have given. Anything that sends or writes is chosen action by + * action: by an administrator, or by the owner one advertised action at a time through + * `POST /grants`, still on the asker's own account and still behind the approval gate. ONE EXCEPTION, the built-in Routines: every action it has keeps, * changes or drops the asking person's own standing instructions and nothing else, and "every * morning, …" is a write — given only its reads, a Bot could list routines and never make one. * From e4eba71ffa2bf33d9674494faf32fc357d0bdbdd Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:59:20 -0300 Subject: [PATCH 41/73] Format the owner grant refusal --- server/src/plugins/route-groups/grants.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/route-groups/grants.ts b/server/src/plugins/route-groups/grants.ts index 51909e15c9..7570622118 100644 --- a/server/src/plugins/route-groups/grants.ts +++ b/server/src/plugins/route-groups/grants.ts @@ -113,12 +113,7 @@ export function mountGrantRoutes({ : null; if (!inspected?.rowIds.includes(serverId ?? "")) return `${serverId} hasn't been added yet. Only an admin can add it.`; - const refusal = await ownerGrantRefusal( - store, - actorId, - agentId, - inspected, - ); + const refusal = await ownerGrantRefusal(store, actorId, agentId, inspected); if (refusal) return refusal; /* * AN ACTION THE APP ADVERTISES NOW, NOT ANY WORD AFTER THE SLASH. An administrator may grant a @@ -127,7 +122,9 @@ export function mountGrantRoutes({ * Bot would then hold it with nobody having chosen it — and a bare server ref would mark the app * given without giving it a single action. */ - if (!(await store.appTools(serverId ?? "")).some((tool) => tool.ref === ref)) + if ( + !(await store.appTools(serverId ?? "")).some((tool) => tool.ref === ref) + ) return `${inspected.access.displayName} doesn't have that action.`; return null; } From 16e75b9da1f98038498ed8b193d37eda5ec29206 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 12:59:58 -0300 Subject: [PATCH 42/73] Tell a Bot its computer is still starting when the supervisor's own health wait runs out The supervisor answers a container that is up but not yet healthy with a 503 after 60s, which is well inside the server's 120s ensure timeout, so the docker and desktop paths never reached the gentle still-starting answer. The supervisor now marks that 503 as starting and the provider maps it to ComputerStillStartingError. Also awaits a rejects assertion that was never checked. --- server/src/computer/supervisor.ts | 10 +++++++ server/tests/computer-supervisor.test.ts | 33 +++++++++++++++++++++++- supervisor/src/index.ts | 10 ++++--- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/server/src/computer/supervisor.ts b/server/src/computer/supervisor.ts index a6f1a46c46..f3347930a3 100644 --- a/server/src/computer/supervisor.ts +++ b/server/src/computer/supervisor.ts @@ -115,8 +115,18 @@ export function createDockerSupervisorProvider( stopped?: boolean; reset?: boolean; computers?: SupervisorComputerLocation[]; + starting?: boolean; } | null; if (!response.ok) { + // The supervisor's own health wait ran out on a container that is up and still booting. The + // same slow start as a timeout here, so it gets the same gentle answer. + if ( + startingOnTimeout && + response.status === 503 && + body?.starting === true + ) { + throw new ComputerStillStartingError(); + } throw new SupervisorError( body?.error ?? `The supervisor answered ${response.status}.`, ); diff --git a/server/tests/computer-supervisor.test.ts b/server/tests/computer-supervisor.test.ts index d8e2870f0a..8e23f548e3 100644 --- a/server/tests/computer-supervisor.test.ts +++ b/server/tests/computer-supervisor.test.ts @@ -108,7 +108,38 @@ describe("locating a Bot's computer", () => { throw new DOMException("The operation timed out.", "TimeoutError"); }) as unknown as typeof fetch, }); - expect(client.stop("sales")).rejects.toThrow(SupervisorError); + await expect(client.stop("sales")).rejects.toThrow(SupervisorError); + }); + + test("an ensure the supervisor answers as still booting is a computer still starting", async () => { + // The supervisor's own health wait ran out on a container that is up, and it says so. + const client = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json( + { + error: "The computer started but did not answer within 60000ms.", + starting: true, + }, + { status: 503 }, + )) as unknown as typeof fetch, + }); + const failure = await client.locate("sales").catch((error) => error); + expect(failure).toBeInstanceOf(ComputerStillStartingError); + }); + + test("a 503 from an unreachable Docker is still the supervisor's fault", async () => { + const client = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json( + { error: "Docker is not reachable." }, + { status: 503 }, + )) as unknown as typeof fetch, + }); + const failure = await client.locate("sales").catch((error) => error); + expect(failure).toBeInstanceOf(SupervisorError); + expect(failure).not.toBeInstanceOf(ComputerStillStartingError); }); test("the bot id is escaped into the path", async () => { diff --git a/supervisor/src/index.ts b/supervisor/src/index.ts index e995abc30d..6478e07eed 100644 --- a/supervisor/src/index.ts +++ b/supervisor/src/index.ts @@ -124,12 +124,14 @@ app.post("/computers/:botId/ensure", async (context) => { } // Not ready is a 503 like an outage is, because the caller's next move is the same: wait and // ask again. The message is what differs, and it is the part an operator acts on. - if ( - error instanceof DockerUnavailableError || - error instanceof ComputerNotAnsweringError - ) { + if (error instanceof DockerUnavailableError) { return context.json({ error: error.message }, 503); } + // Marked `starting`, because the container is up and still booting: the server tells the Bot + // the call was not performed and is worth repeating, instead of reporting an outage. + if (error instanceof ComputerNotAnsweringError) { + return context.json({ error: error.message, starting: true }, 503); + } throw error; } }); From 76117afcfb71b37f63ee839228dd9f0b18f2836f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:01:12 -0300 Subject: [PATCH 43/73] Report a docker computer whose browser is still booting as starting, not ready Docker says running as soon as the process exists, so status called a booting container ready, the screenshot route went on to /ensure, and the poll was held for up to a minute on the health wait. The supervisor now passes the healthcheck verdict through its list, and running with a starting healthcheck reads as starting. An older supervisor sends no verdict and reads as before. --- server/src/computer/supervisor.ts | 8 +++++++- server/tests/computer-supervisor.test.ts | 22 ++++++++++++++++++++++ supervisor/src/docker.ts | 20 ++++++++++++++++++++ supervisor/tests/health.test.ts | 15 +++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 supervisor/tests/health.test.ts diff --git a/server/src/computer/supervisor.ts b/server/src/computer/supervisor.ts index f3347930a3..3e8254adb9 100644 --- a/server/src/computer/supervisor.ts +++ b/server/src/computer/supervisor.ts @@ -25,6 +25,8 @@ type SupervisorComputerLocation = { /** Where to reach it, decided by the supervisor rather than assembled here. */ url?: string; startedAt?: string; + /** The image's healthcheck verdict, from a supervisor new enough to report it. */ + health?: string; }; export type SupervisorOptions = { @@ -165,7 +167,11 @@ export function createDockerSupervisorProvider( const rawStatus = location.status.toLowerCase(); switch (rawStatus) { case "running": - return { botId, state: "ready" }; + // Running is the process; healthy is the browser inside it. Until the healthcheck passes, + // `/ensure` holds every caller, so the computer is still starting. + return location.health === "starting" + ? { botId, state: "starting" } + : { botId, state: "ready" }; case "created": case "restarting": return { botId, state: "starting" }; diff --git a/server/tests/computer-supervisor.test.ts b/server/tests/computer-supervisor.test.ts index 8e23f548e3..2597baa26f 100644 --- a/server/tests/computer-supervisor.test.ts +++ b/server/tests/computer-supervisor.test.ts @@ -199,6 +199,28 @@ describe("Docker supervisor provider", () => { }, ); + test("a running container whose healthcheck is still starting is starting, not ready", async () => { + const provider = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json({ + computers: [ + { botId: "bot", status: "running", health: "starting" }, + { botId: "other", status: "running", health: "healthy" }, + ], + })) as unknown as typeof fetch, + }); + + expect(await provider.status("bot")).toEqual({ + botId: "bot", + state: "starting", + }); + expect(await provider.status("other")).toEqual({ + botId: "other", + state: "ready", + }); + }); + test("reports missing bot as absent", async () => { const provider = createDockerSupervisorProvider({ baseUrl: "http://supervisor:4300", diff --git a/supervisor/src/docker.ts b/supervisor/src/docker.ts index d339054b37..d378f2634e 100644 --- a/supervisor/src/docker.ts +++ b/supervisor/src/docker.ts @@ -93,8 +93,25 @@ export type ComputerState = { port?: number; /** Where to reach it, however it is arranged. */ url?: string; + /** + * What the image's healthcheck says, when Docker reports one. + * + * `running` only means the process exists. A container still `starting` here is booting its + * browser, and `ensure` would hold the caller until it is healthy, so a status read says so. + */ + health?: "starting" | "healthy" | "unhealthy"; }; +/** The healthcheck verdict Docker writes into a container's status line, e.g. "Up 3 seconds (health: starting)". */ +export function healthOf( + status: string | undefined, +): ComputerState["health"] | undefined { + const match = /\((?:health: )?(starting|healthy|unhealthy)\)/.exec( + status ?? "", + ); + return match?.[1] as ComputerState["health"] | undefined; +} + /** * The name this Bot's computer would have is held by a container this supervisor does not own. * @@ -197,6 +214,9 @@ export async function listOwned(): Promise { botId: container.Labels?.[BOT_LABEL] ?? "unknown", container: (container.Names?.[0] ?? "").replace(/^\//, ""), status: container.State, + ...(healthOf(container.Status) + ? { health: healthOf(container.Status) } + : {}), ...(container.Created ? { startedAt: new Date(container.Created * 1000).toISOString() } : {}), diff --git a/supervisor/tests/health.test.ts b/supervisor/tests/health.test.ts new file mode 100644 index 0000000000..488a9e3ea1 --- /dev/null +++ b/supervisor/tests/health.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { healthOf } from "../src/docker"; + +describe("reading a container's healthcheck from its status line", () => { + test.each([ + ["Up 3 seconds (health: starting)", "starting"], + ["Up 5 minutes (healthy)", "healthy"], + ["Up 1 minute (unhealthy)", "unhealthy"], + ["Up 2 hours", undefined], + ["Exited (0) 3 minutes ago", undefined], + [undefined, undefined], + ] as const)("%s", (status, expected) => { + expect(healthOf(status)).toBe(expected); + }); +}); From 10d427095420e7f55301e600a249b6849cdb0557 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:01:40 -0300 Subject: [PATCH 44/73] Answer a cold sandbox start within 45s, under a load balancer's idle timeout locate waited up to 120s inside the person's request. Behind an ALB's default 60s idle timeout the browser got a bare gateway error, and the action could still land once the pod came up after the model had been told it failed. locate now gives up at 45s with the still-starting answer while the pod keeps coming up; update's background restart keeps the full resume budget. --- server/src/computer/sandbox.ts | 31 ++++++++++++++++++++++----- server/tests/computer-sandbox.test.ts | 23 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/server/src/computer/sandbox.ts b/server/src/computer/sandbox.ts index e635a55d28..8766ae8162 100644 --- a/server/src/computer/sandbox.ts +++ b/server/src/computer/sandbox.ts @@ -117,10 +117,24 @@ export type SandboxProviderOptions = { token?: string | (() => Promise); ca?: string; fetchImpl?: typeof fetch; - /** How long `locate` waits for a suspended computer to come back before giving up. */ + /** How long a computer is waited on to come back, by `update`'s restart and at most by `locate`. */ resumeTimeoutMs?: number; + /** + * How long one `locate` waits before answering that the computer is still starting. + * + * Shorter than a resume can take ON PURPOSE. `locate` runs inside a person's request, and a load + * balancer with the usual 60s idle timeout cuts a request that says nothing for longer: the browser + * got a bare gateway error, the action could still land once the pod came up, and the model was + * told it had failed. Answering before that line keeps the "not performed" true. Giving up costs + * nothing, because the resume has already been written and the pod keeps coming up; the next call + * waits again. + */ + locateWaitMs?: number; }; +/** See `locateWaitMs`. Under a 60s load balancer idle timeout, with room for the call itself. */ +const LOCATE_WAIT_MS = 45_000; + /** * The service account this pod was given, which is how it reaches the API server. * @@ -251,6 +265,10 @@ export function createSandboxComputerProvider( } { const doFetch = options.fetchImpl ?? fetch; const resumeTimeoutMs = options.resumeTimeoutMs ?? 120_000; + const locateWaitMs = Math.min( + resumeTimeoutMs, + options.locateWaitMs ?? LOCATE_WAIT_MS, + ); /** Restarts started by `update` and not yet finished, one per Bot. */ const lastRestart = new Map>(); const base = () => @@ -321,8 +339,11 @@ export function createSandboxComputerProvider( }; } - async function waitForReady(botId: string): Promise { - const deadline = Date.now() + resumeTimeoutMs; + async function waitForReady( + botId: string, + budgetMs: number = resumeTimeoutMs, + ): Promise { + const deadline = Date.now() + budgetMs; for (;;) { const sandbox = await read(botId); if (sandbox && isReady(sandbox) && sandbox.status?.serviceFQDN) { @@ -339,7 +360,7 @@ export function createSandboxComputerProvider( JSON.stringify({ type: "computer-sandbox-still-starting", botId, - waitedMs: resumeTimeoutMs, + waitedMs: budgetMs, }), ); throw new ComputerStillStartingError(); @@ -470,7 +491,7 @@ export function createSandboxComputerProvider( // A computer that is running on an older image is left alone here. It may be in the middle of // a task, and restarting it under the Bot is the person's decision, which is `update`. - const ready = await waitForReady(botId); + const ready = await waitForReady(botId, locateWaitMs); const fqdn = ready.status?.serviceFQDN; if (!fqdn) { throw new SandboxError( diff --git a/server/tests/computer-sandbox.test.ts b/server/tests/computer-sandbox.test.ts index fc0a6a0e3a..c37e5eb5ef 100644 --- a/server/tests/computer-sandbox.test.ts +++ b/server/tests/computer-sandbox.test.ts @@ -149,6 +149,29 @@ describe("a computer that takes longer than the wait to start", () => { } }); + test("locate answers within its own wait, well before a resume's budget runs out", async () => { + // Held under a load balancer's idle timeout, so the person's request is answered, not cut. + const provider = createSandboxComputerProvider({ + namespace: "openbot", + template: { podTemplate: { spec: { containers: [] } } }, + idleAfterMs: 60_000, + apiServer: "https://kubernetes.default", + token: "t", + resumeTimeoutMs: 600_000, + locateWaitMs: 0, + fetchImpl: (async () => + Response.json(booting)) as unknown as typeof fetch, + }); + const warn = console.warn; + console.warn = () => undefined; + try { + const failure = await provider.locate("general").catch((error) => error); + expect(failure).toBeInstanceOf(ComputerStillStartingError); + } finally { + console.warn = warn; + } + }); + test("status calls one on its way up starting, without touching it", async () => { const seen: string[] = []; const status = await providerWith(booting, seen).status("general"); From c80d01352981fd2dc468f433caccbf8f250940dd Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:02:24 -0300 Subject: [PATCH 45/73] Keep the computer panel honest when a not-ready answer goes stale A screenshot failure after an 'absent' answer kept the old state, so the side panel went on calling a failing computer off, and an off computer is never timed out, so the failure was never shown. A failure now clears the polled state and the status read resumes. A not-ready answer also drops the last frame, which showed a sleeping computer as live and kept its stream reconnecting. --- app/src/components/computer/computer-view.tsx | 11 ++++ app/tests/computer-calm-start.test.tsx | 57 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx index 8e1395da23..b1735d5e93 100644 --- a/app/src/components/computer/computer-view.tsx +++ b/app/src/components/computer/computer-view.tsx @@ -405,8 +405,19 @@ export function ComputerView({ */ setPolledState(notReady.state); setProblem(null); + /* + * A frame from before the computer went to sleep is not what it shows now, and keeping it + * would also keep the live stream mounted, which reconnects to a computer nobody woke. + */ + setShot(null); } else if (!frame) { setProblem(error ?? "The screen isn't available right now."); + /* + * The state is unknown again, not whatever the last not-ready answer said. A stale + * "absent" would keep calling a computer that is failing "off", and an off computer is + * never timed out, so the failure would never be shown. Cleared, the status read resumes. + */ + setPolledState(null); } else { // Exact byte comparison is the settling signal. unchanged = frame.base64 === lastFrame ? unchanged + 1 : 0; diff --git a/app/tests/computer-calm-start.test.tsx b/app/tests/computer-calm-start.test.tsx index 98e3694298..28447ed8af 100644 --- a/app/tests/computer-calm-start.test.tsx +++ b/app/tests/computer-calm-start.test.tsx @@ -274,3 +274,60 @@ test("a live stream that drops shows a quiet badge, not the Retry panel", async expect(view.getByText("Reconnecting…")).toBeDefined(); expect(view.queryByText("Retry")).toBeNull(); }); + +test("a computer that was off and then fails is not called off for ever", async () => { + // The first answer says off; every one after it is a failed screenshot of a computer now up. + let shots = 0; + serve({ + "/status": () => Response.json({ botId: "sales", state: "ready" }), + "/screenshot": () => + shots++ === 0 + ? Response.json({ ready: false, state: "absent" }) + : Response.json( + { error: "The computer did not respond." }, + { status: 503 }, + ), + }); + + const view = renderWithQueries( + , + ); + + expect( + await view.findByText( + "Can't show the screen right now", + {}, + { timeout: 1000 }, + ), + ).toBeDefined(); + expect(view.queryByText(/is off/)).toBeNull(); +}); + +test("a computer that goes to sleep stops showing its last frame", async () => { + let shots = 0; + serve({ + "/status": () => Response.json({ botId: "sales", state: "absent" }), + "/screenshot": () => + shots++ === 0 ? frame() : Response.json({ ready: false, state: "absent" }), + }); + + const view = renderWithQueries( + , + ); + + await view.findByAltText("What the Bot is looking at"); + await waitFor(() => { + expect( + view.getByText( + "Sales's computer is off. It starts when the Bot next uses it.", + ), + ).toBeDefined(); + expect(view.queryByAltText("What the Bot is looking at")).toBeNull(); + }); +}); From 87abd4a7a5da158536680e118fa1445fc7814585 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:03:03 -0300 Subject: [PATCH 46/73] Format the stale-state computer panel test --- app/tests/computer-calm-start.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/tests/computer-calm-start.test.tsx b/app/tests/computer-calm-start.test.tsx index 28447ed8af..a1c9c63b5a 100644 --- a/app/tests/computer-calm-start.test.tsx +++ b/app/tests/computer-calm-start.test.tsx @@ -314,7 +314,9 @@ test("a computer that goes to sleep stops showing its last frame", async () => { serve({ "/status": () => Response.json({ botId: "sales", state: "absent" }), "/screenshot": () => - shots++ === 0 ? frame() : Response.json({ ready: false, state: "absent" }), + shots++ === 0 + ? frame() + : Response.json({ ready: false, state: "absent" }), }); const view = renderWithQueries( From 2982a030ee138e48d29a166b4afd909fbbdb0a54 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:03:27 -0300 Subject: [PATCH 47/73] Read a conversation's open hops through an index instead of scanning every hop The work list is read on every roster page and every busy event. It filtered work_items on payload->>'threadId' with no index, and joined each relay to its hop by building 'relay:' || key for every row, so both sides scanned a day of finished hops. A partial index on unfinished rows by thread, and a join that cuts the hop key out of the relay key, turn it into two index lookups. --- .../drizzle/0063_work_items_open_thread.sql | 1 + server/drizzle/meta/0063_snapshot.json | 10205 ++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/channels/working.ts | 15 +- server/src/db/schema/work.ts | 9 + 5 files changed, 10235 insertions(+), 2 deletions(-) create mode 100644 server/drizzle/0063_work_items_open_thread.sql create mode 100644 server/drizzle/meta/0063_snapshot.json diff --git a/server/drizzle/0063_work_items_open_thread.sql b/server/drizzle/0063_work_items_open_thread.sql new file mode 100644 index 0000000000..82f3b30a95 --- /dev/null +++ b/server/drizzle/0063_work_items_open_thread.sql @@ -0,0 +1 @@ +CREATE INDEX "work_items_open_thread_idx" ON "work_items" USING btree ("kind",("payload"->>'threadId')) WHERE "work_items"."finished_at" IS NULL; \ No newline at end of file diff --git a/server/drizzle/meta/0063_snapshot.json b/server/drizzle/meta/0063_snapshot.json new file mode 100644 index 0000000000..9d9918d32b --- /dev/null +++ b/server/drizzle/meta/0063_snapshot.json @@ -0,0 +1,10205 @@ +{ + "id": "6fe50b30-070c-422e-9cd4-fbb9a492da1f", + "prevId": "20465172-4c5a-4639-919a-d1f8d832e6ed", + "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.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_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": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "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": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_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 + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_access_requests": { + "name": "app_access_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_key": { + "name": "app_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_title": { + "name": "app_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "app_access_requests_pending_idx": { + "name": "app_access_requests_pending_idx", + "columns": [ + { + "expression": "requested_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"agent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"app_access_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "app_access_requests_status_idx": { + "name": "app_access_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_access_requests_requested_by_users_id_fk": { + "name": "app_access_requests_requested_by_users_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_access_requests_agent_id_agents_id_fk": { + "name": "app_access_requests_agent_id_agents_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_access_requests_kind_check": { + "name": "app_access_requests_kind_check", + "value": "\"app_access_requests\".\"kind\" IN ('enable', 'grant', 'connect_shared')" + }, + "app_access_requests_status_check": { + "name": "app_access_requests_status_check", + "value": "\"app_access_requests\".\"status\" IN ('pending', 'approved', 'dismissed', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "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 + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_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 + }, + "skipped_tools": { + "name": "skipped_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "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 + }, + "vendor_name": { + "name": "vendor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_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 + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pending_authorizations_server_idx": { + "name": "pending_authorizations_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_authorizations_actor_id_users_id_fk": { + "name": "pending_authorizations_actor_id_users_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + }, + "pending_authorizations_completion_check": { + "name": "pending_authorizations_completion_check", + "value": "(\"pending_authorizations\".\"completed_at\" IS NULL) = (\"pending_authorizations\".\"outcome\" IS NULL)" + }, + "pending_authorizations_user_is_actor_check": { + "name": "pending_authorizations_user_is_actor_check", + "value": "\"pending_authorizations\".\"user_id\" IS NULL OR \"pending_authorizations\".\"user_id\" = \"pending_authorizations\".\"actor_id\"" + } + }, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_open_thread_idx": { + "name": "work_items_open_thread_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'threadId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"work_items\".\"finished_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_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": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "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()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "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": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "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": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_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": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_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()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "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": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "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": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "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.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "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()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index a3b2138466..d36dc6bd7c 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -442,6 +442,13 @@ "when": 1793016088183, "tag": "0062_action_policy_labels", "breakpoints": true + }, + { + "idx": 63, + "version": "7", + "when": 1793030330893, + "tag": "0063_work_items_open_thread", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/channels/working.ts b/server/src/channels/working.ts index 60b5b48182..8b74f02c0c 100644 --- a/server/src/channels/working.ts +++ b/server/src/channels/working.ts @@ -25,6 +25,7 @@ import { gt, inArray, isNull, + like, lt, lte, notLike, @@ -116,7 +117,8 @@ export async function workIn( * of when the question was put. */ const asked = alias(workItems, "asked"); - const threadOf = sql`${workItems.payload}->>'threadId'`; + // The same expression as `work_items_open_thread_idx`, so the read uses it. See work.ts. + const threadOf = sql`(${workItems.payload}->>'threadId')`; const hops = await database .select({ key: workItems.key, @@ -130,7 +132,16 @@ export async function workIn( asked, and( eq(asked.kind, workItems.kind), - sql`${workItems.key} = ${RELAY_PREFIX} || ${asked.key}`, + like(workItems.key, `${RELAY_PREFIX}%`), + /* + * The asked key cut out of the relay's, rather than the relay's built from every asked + * key: this way round it is a primary-key lookup per relay, and the other way round is a + * scan of every hop the table holds. + */ + eq( + asked.key, + sql`substr(${workItems.key}, ${RELAY_PREFIX.length + 1})`, + ), ), ) .where( diff --git a/server/src/db/schema/work.ts b/server/src/db/schema/work.ts index 6f6cb18a25..00d49a3cd5 100644 --- a/server/src/db/schema/work.ts +++ b/server/src/db/schema/work.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { index, integer, @@ -94,5 +95,13 @@ export const workItems = pgTable( primaryKey({ columns: [table.kind, table.key] }), // The claim's own query: due, unclaimed or expired, not yet finished, oldest first. index("work_items_claimable_idx").on(table.kind, table.runAt), + /* + * What a conversation is waiting on: the open hops asked from a thread. Read on every roster + * page and every busy event, so it cannot scan a day of finished hops each time. Partial on + * unfinished rows, which keeps it as small as the queue's live work. See channels/working.ts. + */ + index("work_items_open_thread_idx") + .on(table.kind, sql`(${table.payload}->>'threadId')`) + .where(sql`${table.finishedAt} IS NULL`), ], ); From 821597a57b99216207b0e94c60f1bbc8f9975e91 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:03:35 -0300 Subject: [PATCH 48/73] Keep what a conversation waits on to the person whose thread it is Every member of a shared channel has a thread of their own in it. The work list was broadcast to all members on every busy event, read from whichever thread the signal came from, so another member learned which Bot somebody asked and lost their own list to it; the roster also listed every member's routine runs. Hops are now read from the reader's thread and routines from the reader's own; the list goes only to the thread's owner, and the other members hear the busy flag alone, as before. Each list is stamped with the database clock just before it is read, so a browser can tell an older list that arrived late from a newer one. --- server/src/channels/events.ts | 6 + server/src/channels/routes.ts | 98 +++++++++--- server/src/channels/working.ts | 21 ++- server/tests/channel-work.integration.test.ts | 148 +++++++++++++++--- 4 files changed, 229 insertions(+), 44 deletions(-) diff --git a/server/src/channels/events.ts b/server/src/channels/events.ts index c4c2dc6466..9dc26eeb08 100644 --- a/server/src/channels/events.ts +++ b/server/src/channels/events.ts @@ -55,6 +55,12 @@ export type ChannelActivityEvent = { * is also what the roster and the channel read return. See `working.ts`. */ work?: ChannelWorkWire[]; + /** + * When `work` was read, by the database's clock: ISO-8601 in UTC to the microsecond, fixed width + * so two compare as strings. Present whenever `work` is. Events + * can arrive out of order across replicas, so a browser keeps the list with the latest stamp. + */ + workAt?: string; }; /** "The roster you hold may be wrong." Carries no delta, because what was lost is not recoverable. */ diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index dbc855c1b6..f1226d76c6 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -243,8 +243,12 @@ export type ChannelStore = { * turned into a relay. Every busy signal carries the same list, so this is only for those. */ signalWork(threadId: string): Promise; - /** What each of these channels is waiting on. See `workIn` in working.ts. */ + /** + * What each of these channels is waiting on, for this person: their own thread's hops and their + * own routines. See `workIn` in working.ts. + */ workIn( + actor: AgentActor, channels: readonly { id: string; threadId: string }[], ): Promise>; }; @@ -338,10 +342,18 @@ export function createChannelStore( * truth. Just the announcement, carrying the members the same way recordActivity does. The list * rides on every one of these so a client never merges a delta into what it holds: the newest * event is the whole answer, and the same answer a reload gets. + * + * THE LIST GOES ONLY TO THE PERSON WHOSE THREAD IT IS. Each member has a thread of their own in + * a channel, and what that thread waits on names the Bots its owner asked. The other members + * hear that a turn started or ended, as they always have, and nothing about whom it asked. + * + * `workAt` is the database's clock just before the list was read. Two announcements for one + * channel can be read on two replicas and delivered in either order, so the browser keeps the + * list with the latest `workAt` rather than the one that happened to arrive last. */ const announceWork = async ( channelId: string, - threadId: string | null, + thread: { threadId: string; userId: string } | null, busy?: boolean, ): Promise => { const members = await database @@ -350,23 +362,47 @@ export function createChannelStore( .where(eq(channelMemberships.channelId, channelId)); if (members.length === 0) return; - const work = threadId - ? ((await workIn(database, [{ id: channelId, threadId }])).get( - channelId, - ) ?? []) - : []; - const event: ChannelActivityEvent = { + const notify = (event: ChannelActivityEvent) => + database.execute( + sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`, + ); + const base = { channelId, - memberIds: members.map((member) => member.userId), lastMessage: null, lastMessageAt: null, lastMessageAgentId: null, ...(busy === undefined ? {} : { busy }), - work: work.map(channelWorkDto), }; - await database.execute( - sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`, - ); + + const owner = + thread && members.some((member) => member.userId === thread.userId) + ? thread + : null; + const others = members + .map((member) => member.userId) + .filter((userId) => userId !== owner?.userId); + + if (owner) { + // Fixed-width UTC to the microsecond, so two stamps compare as strings. + const [stamp] = await database.execute<{ at: string }>( + sql`select to_char(clock_timestamp() at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as at`, + ); + const work = + ( + await workIn(database, [ + { id: channelId, threadId: owner.threadId, userId: owner.userId }, + ]) + ).get(channelId) ?? []; + await notify({ + ...base, + memberIds: [owner.userId], + work: work.map(channelWorkDto), + ...(stamp ? { workAt: stamp.at } : {}), + }); + } + // A settle with no turn moving has nothing to tell anybody but the thread's owner. + if (busy !== undefined && others.length > 0) + await notify({ ...base, memberIds: others }); }; const store: ChannelStore = { @@ -884,22 +920,32 @@ export function createChannelStore( // The channel this thread is shown in, if any. A scratch thread maps to nothing, so a hop // running there signals nowhere and the branch below returns without announcing. const [mapped] = await database - .select({ channelId: intelligenceChannelMappings.channelId }) + .select({ + channelId: intelligenceChannelMappings.channelId, + userId: intelligenceChannelMappings.userId, + }) .from(intelligenceChannelMappings) .where(eq(intelligenceChannelMappings.threadId, threadId)) .limit(1); if (!mapped) return; - await announceWork(mapped.channelId, threadId, busy); + await announceWork( + mapped.channelId, + { threadId, userId: mapped.userId }, + busy, + ); }, async signalWork(threadId) { const [mapped] = await database - .select({ channelId: intelligenceChannelMappings.channelId }) + .select({ + channelId: intelligenceChannelMappings.channelId, + userId: intelligenceChannelMappings.userId, + }) .from(intelligenceChannelMappings) .where(eq(intelligenceChannelMappings.threadId, threadId)) .limit(1); if (!mapped) return; - await announceWork(mapped.channelId, threadId); + await announceWork(mapped.channelId, { threadId, userId: mapped.userId }); }, async signalChannelBusy(actor, channelId, busy) { @@ -938,10 +984,20 @@ export function createChannelStore( // to a channel is not something an outsider can probe for. if (!membership) throw new ChannelNotFoundError(channelId); - await announceWork(channelId, membership.threadId, busy); + await announceWork( + channelId, + membership.threadId + ? { threadId: membership.threadId, userId: membership.userId } + : null, + busy, + ); }, - workIn: (list) => workIn(database, list), + workIn: (actor, list) => + workIn( + database, + list.map((channel) => ({ ...channel, userId: actor.id })), + ), }; return store; } @@ -1175,7 +1231,7 @@ export function createChannelRoutes( * flag that only ever arrived as an event was gone on every reload, so a conversation a Bot * was still answering in looked finished to anybody who refreshed. */ - const work = await store.workIn(page.channels); + const work = await store.workIn(context.var.actor, page.channels); return context.json({ channels: page.channels.map((channel) => channelSummaryDto(channel, work.get(channel.id) ?? []), @@ -1282,7 +1338,7 @@ export function createChannelRoutes( if (!channel) { return context.json({ error: "Couldn't find that conversation." }, 404); } - const work = await store.workIn([channel]); + const work = await store.workIn(context.var.actor, [channel]); return context.json({ channel: { ...channelDto(channel), diff --git a/server/src/channels/working.ts b/server/src/channels/working.ts index 8b74f02c0c..19e735f360 100644 --- a/server/src/channels/working.ts +++ b/server/src/channels/working.ts @@ -92,19 +92,35 @@ type HandoffPayload = { const text = (value: unknown): string | null => typeof value === "string" && value.length > 0 ? value : null; +/** A conversation as one person holds it: the channel, and that person's own thread in it. */ +export type ChannelThread = { + id: string; + /** The thread this person's Bot runs in. Hops are offered from it, so it scopes theirs. */ + threadId: string; + /** The person. Their routines fire into this thread; another member's do not. */ + userId: string; +}; + /** * What each of these conversations is waiting on, oldest first. A conversation waiting on nothing * is absent from the map. + * + * SCOPED TO THE PERSON, never to the channel. Every member of a channel has a thread of their own + * in it, and the hops and routines in that thread are theirs: another member reading the same + * channel must not learn which Bot somebody else asked, or which Bot their routine runs as. */ export async function workIn( database: Database, - channels: readonly { id: string; threadId: string }[], + channels: readonly ChannelThread[], ): Promise> { const found = new Map(); if (channels.length === 0) return found; const channelByThread = new Map( channels.map((channel) => [channel.threadId, channel.id]), ); + const ownerByChannel = new Map( + channels.map((channel) => [channel.id, channel.userId]), + ); const add = (channelId: string, work: ChannelWork) => { const list = found.get(channelId) ?? []; list.push(work); @@ -200,6 +216,7 @@ export async function workIn( runId: routineRuns.id, routineId: routines.id, channelId: routines.channelId, + ownerUserId: routines.ownerUserId, botId: routines.agentId, botName: agents.name, startedAt: routineRuns.startedAt, @@ -221,6 +238,8 @@ export async function workIn( ), ); for (const run of runs) { + // Somebody else's routine, firing into their own thread in a channel this person shares. + if (ownerByChannel.get(run.channelId) !== run.ownerUserId) continue; add(run.channelId, { kind: "routine", key: run.runId, diff --git a/server/tests/channel-work.integration.test.ts b/server/tests/channel-work.integration.test.ts index babc3db3b9..425dcf1589 100644 --- a/server/tests/channel-work.integration.test.ts +++ b/server/tests/channel-work.integration.test.ts @@ -15,6 +15,7 @@ import { createDatabase } from "../src/db/client"; import { agentProfiles, agents, + channelMemberships, channels, intelligenceChannelMappings, routineRuns, @@ -150,16 +151,61 @@ async function hop(input: { return key; } -async function workFor(channel: { id: string; threadId: string }) { - return (await store.workIn([channel])).get(channel.id) ?? []; +async function workFor( + owner: AgentActor, + channel: { id: string; threadId: string }, +) { + return (await store.workIn(owner, [channel])).get(channel.id) ?? []; +} + +/** A second person in the channel, with a thread of their own in it, as `group.ts` adds one. */ +async function addMember(channelId: string) { + const member = await createUser(); + const threadId = `${testPrefix}-thread-${randomUUID()}`; + await database + .insert(channelMemberships) + .values({ channelId, userId: member.id }); + await database + .insert(intelligenceChannelMappings) + .values({ userId: member.id, channelId, threadId }); + return { member, channel: { id: channelId, threadId } }; +} + +/** Hear what the activity socket would carry to each of these people. */ +async function listen(people: AgentActor[]) { + const heard = new Map( + people.map((person) => [person.id, []]), + ); + const hub = createChannelEventHub(); + const detachers = people.map((person) => + hub.register(person.id, (payload) => { + heard.get(person.id)?.push(JSON.parse(payload) as ChannelActivityEvent); + }), + ); + const listener = await startChannelActivityListener(databaseUrl, hub); + return { + heard, + async until( + count: (heard: Map) => boolean, + ) { + const deadline = Date.now() + 5_000; + while (!count(heard) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }, + async stop() { + for (const detach of detachers) detach(); + await listener.stop(); + }, + }; } describe("what a conversation is waiting on", () => { test("a Bot it asked, from the moment the hop is queued", async () => { - const { channel } = await setup(); + const { owner, channel } = await setup(); const key = await hop({ threadId: channel.threadId, toName: "Ana" }); - const work = await workFor(channel); + const work = await workFor(owner, channel); expect(work).toHaveLength(1); expect(work[0]).toMatchObject({ @@ -172,21 +218,21 @@ describe("what a conversation is waiting on", () => { }); test("nothing, once the hop is finished", async () => { - const { channel } = await setup(); + const { owner, channel } = await setup(); await hop({ threadId: channel.threadId, finished: true }); - expect(await workFor(channel)).toEqual([]); + expect(await workFor(owner, channel)).toEqual([]); }); test("one entry for a hop however many times it was tried", async () => { - const { channel } = await setup(); + const { owner, channel } = await setup(); await hop({ threadId: channel.threadId, attempts: 3 }); - expect(await workFor(channel)).toHaveLength(1); + expect(await workFor(owner, channel)).toHaveLength(1); }); test("two Bots asked at once are two entries, oldest first", async () => { - const { channel } = await setup(); + const { owner, channel } = await setup(); await hop({ threadId: channel.threadId, toBotId: "ben", @@ -198,13 +244,13 @@ describe("what a conversation is waiting on", () => { createdAt: new Date(Date.now() - 5_000), }); - const work = await workFor(channel); + const work = await workFor(owner, channel); expect(work.map((entry) => entry.botId)).toEqual(["ana", "ben"]); }); test("not a hop that was given up on, nor the notice saying so", async () => { - const { channel } = await setup(); + const { owner, channel } = await setup(); const key = await hop({ threadId: channel.threadId, attempts: 5 }); await hop({ threadId: channel.threadId, @@ -212,18 +258,18 @@ describe("what a conversation is waiting on", () => { answerIn: channel.threadId, }); - expect(await workFor(channel)).toEqual([]); + expect(await workFor(owner, channel)).toEqual([]); }); test("a given-up hop's last attempt, while it is still running", async () => { - const { channel } = await setup(); + const { owner, channel } = await setup(); await hop({ threadId: channel.threadId, attempts: 5, claimed: true }); - expect(await workFor(channel)).toHaveLength(1); + expect(await workFor(owner, channel)).toHaveLength(1); }); test("an answer on its way back is the same wait, with the same clock", async () => { - const { channel } = await setup(); + const { owner, channel } = await setup(); const askedAt = new Date(Date.now() - 30_000); const key = await hop({ threadId: channel.threadId, @@ -239,7 +285,7 @@ describe("what a conversation is waiting on", () => { answerIn: channel.threadId, }); - const work = await workFor(channel); + const work = await workFor(owner, channel); expect(work).toHaveLength(1); expect(work[0]).toMatchObject({ @@ -252,7 +298,7 @@ describe("what a conversation is waiting on", () => { }); test("not once the answer is being given", async () => { - const { channel } = await setup(); + const { owner, channel } = await setup(); const key = await hop({ threadId: channel.threadId, finished: true }); await hop({ threadId: channel.threadId, @@ -261,14 +307,14 @@ describe("what a conversation is waiting on", () => { claimed: true, }); - expect(await workFor(channel)).toEqual([]); + expect(await workFor(owner, channel)).toEqual([]); }); test("not a hop asked from a thread no conversation shows", async () => { - const { channel } = await setup(); + const { owner, channel } = await setup(); await hop({ threadId: `scratch-${testPrefix}` }); - expect(await workFor(channel)).toEqual([]); + expect(await workFor(owner, channel)).toEqual([]); }); test("a routine firing into it, until the run has an outcome", async () => { @@ -287,7 +333,7 @@ describe("what a conversation is waiting on", () => { const runId = `${testPrefix}-run`; await database.insert(routineRuns).values({ id: runId, routineId }); - const running = await workFor(channel); + const running = await workFor(owner, channel); expect(running).toHaveLength(1); expect(running[0]).toMatchObject({ kind: "routine", @@ -300,7 +346,30 @@ describe("what a conversation is waiting on", () => { .update(routineRuns) .set({ status: "succeeded", finishedAt: new Date() }) .where(eq(routineRuns.id, runId)); - expect(await workFor(channel)).toEqual([]); + expect(await workFor(owner, channel)).toEqual([]); + }); + test("only what this person's own thread and routines wait on, in a shared channel", async () => { + const { owner, asker, channel } = await setup(); + const { member, channel: theirs } = await addMember(channel.id); + await hop({ threadId: channel.threadId, toName: "Ana" }); + const routineId = `${testPrefix}-routine-shared`; + await database.insert(routines).values({ + id: routineId, + ownerUserId: owner.id, + agentId: asker, + channelId: channel.id, + instruction: "Post the standup summary.", + cron: "0 9 * * *", + nextRunAt: new Date(Date.now() + 86_400_000), + }); + createdRoutineIds.push(routineId); + await database + .insert(routineRuns) + .values({ id: `${testPrefix}-run-shared`, routineId }); + + expect(await workFor(owner, channel)).toHaveLength(2); + // The other member's read names neither the Bot the owner asked nor the owner's routine. + expect(await workFor(member, theirs)).toEqual([]); }); }); @@ -350,4 +419,39 @@ describe("announcing it", () => { await listener.stop(); } }); + + test("another member hears that a turn moved, and nothing about whom it asked", async () => { + const { owner, channel } = await setup(); + const { member } = await addMember(channel.id); + const socket = await listen([owner, member]); + try { + await hop({ threadId: channel.threadId, toName: "Ana" }); + await store.signalBusy(channel.threadId, true); + await store.signalWork(channel.threadId); + await socket.until( + (heard) => + (heard.get(owner.id)?.length ?? 0) >= 2 && + (heard.get(member.id)?.length ?? 0) >= 1, + ); + + const ownerHeard = socket.heard.get(owner.id) ?? []; + const memberHeard = socket.heard.get(member.id) ?? []; + expect(ownerHeard).toHaveLength(2); + expect(ownerHeard[0]?.work).toHaveLength(1); + expect(ownerHeard[0]?.workAt).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/); + // Stamped in the order they were read, so a browser can drop the older of two. + expect( + (ownerHeard[0]?.workAt ?? "") < (ownerHeard[1]?.workAt ?? ""), + ).toBe(true); + // The settle is the owner's alone; the busy signal reaches the member without the list. + expect(memberHeard).toHaveLength(1); + expect(memberHeard[0]).toMatchObject({ + channelId: channel.id, + busy: true, + }); + expect(memberHeard[0]?.work).toBeUndefined(); + } finally { + await socket.stop(); + } + }); }); From 758de811886a759ae1a3fcbd57489e46993af912 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:03:35 -0300 Subject: [PATCH 49/73] Ignore a work list read before the one a conversation already holds Busy events are read on whichever replica sent them and can arrive out of order. The roster and the open conversation now keep the list with the latest stamp, so a late event no longer puts back a hop that has already settled. --- app/src/lib/channels/queries.ts | 5 ++ app/src/lib/channels/use-channel-events.ts | 34 ++++++++++-- app/tests/channel-event-patch.test.ts | 60 +++++++++++++++++++++- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts index b7e2b9ee15..a7a32fc88f 100644 --- a/app/src/lib/channels/queries.ts +++ b/app/src/lib/channels/queries.ts @@ -28,6 +28,11 @@ export type AgentChannel = { * the socket last said. Absent from a server that predates it, which reads as nothing. */ work?: ChannelWork[]; + /** + * When the server read `work`, from the socket event that last set it. Absent on a fetched read. + * Kept so an older event arriving late does not put back a list that has moved on. + */ + workAt?: string; }; /** diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts index 8b3a07819a..9e0d4d79c3 100644 --- a/app/src/lib/channels/use-channel-events.ts +++ b/app/src/lib/channels/use-channel-events.ts @@ -102,6 +102,12 @@ export type ChannelActivityEvent = { * settles without a turn starting or ending. */ work?: ChannelWork[]; + /** + * When the server read `work`, as fixed-width UTC that compares as a string. Two events can + * arrive in the other order from the one they were read in, so a list older than the one already + * held is ignored. See `newerWork`. + */ + workAt?: string; }; /** The infinite query's cache, which holds pages rather than one array. */ @@ -185,7 +191,8 @@ export function applyChannelEvent( */ if (activity.busy !== undefined || activity.work !== undefined) { const busy = activity.busy ?? previous.busy; - const work = activity.work ?? previous.work; + const fresh = newerWork(previous, activity); + const work = fresh ? activity.work : previous.work; if ( (previous.busy ?? false) === (busy ?? false) && sameWork(previous.work, work) @@ -196,6 +203,7 @@ export function applyChannelEvent( ...previous, ...(busy === undefined ? {} : { busy }), ...(work === undefined ? {} : { work }), + ...(fresh && activity.workAt ? { workAt: activity.workAt } : {}), }; const pages = data.pages.slice(); pages[holdingPage] = { ...page, channels }; @@ -237,6 +245,22 @@ function sameWork( ); } +/** + * Whether this event's work list should replace the one held. + * + * Only when it carries one, and was not read before the one already held. A list from a fetch has + * no stamp and gives way to any event; an event without a stamp, from an older server, always + * wins, as every event did before stamps. + */ +function newerWork( + held: { workAt?: string }, + activity: ChannelActivityEvent, +): boolean { + if (activity.work === undefined) return false; + if (!held.workAt || !activity.workAt) return true; + return activity.workAt >= held.workAt; +} + /** * Apply one event's work list to the open conversation's own read. * @@ -248,9 +272,13 @@ export function applyChannelWork( channel: AgentChannel | undefined, activity: ChannelActivityEvent, ): AgentChannel | undefined { - if (!channel || activity.work === undefined) return channel; + if (!channel || !newerWork(channel, activity)) return channel; if (sameWork(channel.work, activity.work)) return channel; - return { ...channel, work: activity.work }; + return { + ...channel, + work: activity.work, + ...(activity.workAt ? { workAt: activity.workAt } : {}), + }; } const FIRST_RETRY_MS = 500; diff --git a/app/tests/channel-event-patch.test.ts b/app/tests/channel-event-patch.test.ts index 93c7ae2bc4..7907406934 100644 --- a/app/tests/channel-event-patch.test.ts +++ b/app/tests/channel-event-patch.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test"; -import type { ChannelPage, ChannelSummary } from "../src/lib/channels/queries"; +import type { + AgentChannel, + ChannelPage, + ChannelSummary, +} from "../src/lib/channels/queries"; import { applyChannelEvent, + applyChannelWork, type ChannelActivityEvent, } from "../src/lib/channels/use-channel-events"; @@ -285,6 +290,59 @@ describe("a work list", () => { ), ).toBe(data); }); + + test("an older list arriving late does not undo a newer one", () => { + const settled = applyChannelEvent( + cache([channel("a", { busy: true, work })]), + event({ + channelId: "a", + work: [], + workAt: "2026-10-26T10:00:02.000000Z", + }), + ); + if (settled === "unknown") throw new Error("expected a patch"); + + const late = applyChannelEvent( + settled, + event({ + channelId: "a", + busy: false, + work, + workAt: "2026-10-26T10:00:01.000000Z", + }), + ); + + if (late === "unknown") throw new Error("expected a patch"); + // The busy flag still moves; the list read before the settle is ignored. + expect(late.pages[0]?.channels[0]?.busy).toBe(false); + expect(late.pages[0]?.channels[0]?.work).toEqual([]); + }); + + test("the open conversation's read ignores an older list too", () => { + const held: AgentChannel = { + id: "a", + name: "A", + agentIds: [], + threadId: "thread-a", + active: true, + lastMessageAt: null, + work: [], + workAt: "2026-10-26T10:00:02.000000Z", + }; + + expect( + applyChannelWork( + held, + event({ channelId: "a", work, workAt: "2026-10-26T10:00:01.000000Z" }), + ), + ).toBe(held); + expect( + applyChannelWork( + held, + event({ channelId: "a", work, workAt: "2026-10-26T10:00:03.000000Z" }), + )?.work, + ).toEqual(work); + }); }); /** From 7444d7948e8543947569fe88afb45f457e15d1a1 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:04:28 -0300 Subject: [PATCH 50/73] Keep the assertion-shape guard green across the work-list tests The branch moved four allow-listed offences down their files, and its own tests added four new ones: an absent busy flag and work list asserted through a ?. chain, and an expected undefined key in a toEqual. The moved entries are re-keyed; the new ones assert the missing key directly instead. --- server/tests/assertion-shape.test.ts | 16 ++++++++++++---- server/tests/channel-routes.test.ts | 3 ++- server/tests/channel-work.integration.test.ts | 5 +++-- server/tests/routine-runner.test.ts | 4 +--- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 83f13618c2..8aaedeeff2 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -625,7 +625,9 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/agent-handoff.test.ts", - line: 366, + // Moved from 366 by the work-list change, which added tests above it; the offence is the same one and + // is re-keyed rather than re-found. + line: 409, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(plain.events[0]?.initiator).toBe(undefined);", @@ -814,7 +816,9 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/agent-handoff.test.ts", - line: 620, + // Moved from 620 by the work-list change, which added tests above it; the offence is the same one and + // is re-keyed rather than re-found. + line: 663, shape: "c", finding: "NEW (round 2 did not find this)", why: ').toEqual([ { eventType: "agent.handoff_offered", reason: undefined }, { eventType: "agent.handoff_refused", r', @@ -869,14 +873,18 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/routine-runner.test.ts", - line: 169, + // Moved from 169 by the work-list change, which added tests above it; the offence is the same one and + // is re-keyed rather than re-found. + line: 185, shape: "c", finding: "NEW (round 2 did not find this)", why: 'expect(recorded.finished).toEqual([ { runId: RUN_ID, status: "succeeded", error: undefined }, ]);', }, { file: "server/tests/routine-runner.test.ts", - line: 272, + // Moved from 272 by the work-list change, which added tests above it; the offence is the same one and + // is re-keyed rather than re-found. + line: 289, shape: "c", finding: "NEW (round 2 did not find this)", why: 'expect(recorded.finished).toEqual([ { runId: RUN_ID, status: "succeeded", error: undefined }, ]);', diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts index 18bbd9cc85..2109e1ac3a 100644 --- a/server/tests/channel-routes.test.ts +++ b/server/tests/channel-routes.test.ts @@ -232,7 +232,8 @@ describe("what a channel is waiting on", () => { expect(body.channels[0]).toMatchObject({ id: "channel-1", busy: true }); expect(body.channels[0]?.work).toHaveLength(1); // Absent rather than false: a person's own run in another tab is not on any row this reads. - expect(body.channels[1]?.busy).toBeUndefined(); + expect(body.channels[1]).toBeDefined(); + expect(body.channels[1]).not.toHaveProperty("busy"); expect(body.channels[1]?.work).toEqual([]); }); }); diff --git a/server/tests/channel-work.integration.test.ts b/server/tests/channel-work.integration.test.ts index 425dcf1589..5e2a721fc1 100644 --- a/server/tests/channel-work.integration.test.ts +++ b/server/tests/channel-work.integration.test.ts @@ -413,7 +413,8 @@ describe("announcing it", () => { ]); // A settle says what is left without claiming a turn started or ended. expect(heard[1]).toMatchObject({ channelId: channel.id, work: [] }); - expect(heard[1]?.busy).toBeUndefined(); + expect(heard[1]).toBeDefined(); + expect(heard[1]).not.toHaveProperty("busy"); } finally { detach(); await listener.stop(); @@ -449,7 +450,7 @@ describe("announcing it", () => { channelId: channel.id, busy: true, }); - expect(memberHeard[0]?.work).toBeUndefined(); + expect(memberHeard[0]).not.toHaveProperty("work"); } finally { await socket.stop(); } diff --git a/server/tests/routine-runner.test.ts b/server/tests/routine-runner.test.ts index 91cf8bd954..5e01693070 100644 --- a/server/tests/routine-runner.test.ts +++ b/server/tests/routine-runner.test.ts @@ -393,9 +393,7 @@ describe("createRoutineRunner", () => { await runner.run(RUN_ID); expect(recorded.turns).toHaveLength(1); - expect(recorded.finished).toEqual([ - { runId: RUN_ID, status: "succeeded", error: undefined }, - ]); + expect(recorded.finished).toEqual([{ runId: RUN_ID, status: "succeeded" }]); }); test("signals nothing for a firing that never runs", async () => { From 4d5537ffad1c32b2ccb035744e7ab6ce81f9c8c1 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:07:06 -0300 Subject: [PATCH 51/73] Check the first-action line in both computer guidance variants --- shared/bot-prompt.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/shared/bot-prompt.test.ts b/shared/bot-prompt.test.ts index 5aba6fc0ac..9656c3e76c 100644 --- a/shared/bot-prompt.test.ts +++ b/shared/bot-prompt.test.ts @@ -103,8 +103,10 @@ describe("computerGuidance", () => { test("says what it is about to do before its first computer action", () => { // A first action can sit through a cold start; a sentence first means the wait has a reason. - expect(COMPUTER_GUIDANCE).toContain( - "Before your first computer action in a turn, say in one sentence what you are about to do and where.", - ); + for (const search of [true, false]) { + expect(computerGuidance({ search })).toContain( + "Before your first computer action in a turn, say in one sentence what you are about to do and where.", + ); + } }); }); From 38a8cfb181364e743a431a29311ca813b2e2bbde Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:14:56 -0300 Subject: [PATCH 52/73] Follow the moved headless-components lines in the assertion-shape allow-list --- server/tests/assertion-shape.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 83f13618c2..6b9b6ea636 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -716,14 +716,14 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/headless-components.integration.test.ts", - line: 349, + line: 350, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(bar?.hidden).toBeUndefined();", }, { file: "server/tests/headless-components.test.ts", - line: 197, + line: 202, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(bar?.hidden).toBeUndefined();", From 14e483db03d42aafdbcf12dec972d0d506b38877 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:15:01 -0300 Subject: [PATCH 53/73] Follow the moved computer-supervisor and computer-sandbox lines in the assertion-shape allow-list --- server/tests/assertion-shape.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 83f13618c2..7790ddbdeb 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -555,21 +555,21 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/computer-supervisor.test.ts", - line: 64, + line: 65, shape: "a", finding: "NEW (round 2 did not find this)", why: 'expect(client.locate("sales")).rejects.toThrow(SupervisorError);', }, { file: "server/tests/computer-supervisor.test.ts", - line: 74, + line: 75, shape: "a", finding: "NEW (round 2 did not find this)", why: 'expect(client.locate("bad id")).rejects.toThrow( "A bot id may contain only letters.", );', }, { file: "server/tests/computer-supervisor.test.ts", - line: 88, + line: 89, shape: "a", finding: "NEW (round 2 did not find this)", why: 'expect(client.locate("sales")).rejects.toThrow(/could not be reached/);', @@ -709,7 +709,7 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/computer-sandbox.test.ts", - line: 78, + line: 79, shape: "b", finding: "NEW (round 2 did not find this)", why: 'expect( await providerWith(suspended).sessionOf?.("knowledge"), ).toBeUndefined();', From 9e7424e23f13562023e2554022f5ffb15abf7f54 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:15:36 -0300 Subject: [PATCH 54/73] Hold the admin's approve call to an absent body, so the assertion can fail --- app/tests/app-access-action.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/tests/app-access-action.test.tsx b/app/tests/app-access-action.test.tsx index 89fb1d0a41..904d235ab3 100644 --- a/app/tests/app-access-action.test.tsx +++ b/app/tests/app-access-action.test.tsx @@ -479,7 +479,7 @@ test.each([ ).toBeNull(); fireEvent.click(button); await waitFor(() => - expect(sent.find((call) => call.method === "POST")).toEqual({ + expect(sent.find((call) => call.method === "POST")).toStrictEqual({ method: "POST", url: "/api/plugins/for/expenses/apps/composio-gmail/approve", body: undefined, From 93070cabe90e2285b5242a01074b4046e539824a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:15:56 -0300 Subject: [PATCH 55/73] Hold the owner's give call to an absent body, and follow the moved siblingReads line in the allow-list --- app/tests/app-access-action.test.tsx | 2 +- server/tests/assertion-shape.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/tests/app-access-action.test.tsx b/app/tests/app-access-action.test.tsx index 726257314d..5746399762 100644 --- a/app/tests/app-access-action.test.tsx +++ b/app/tests/app-access-action.test.tsx @@ -556,7 +556,7 @@ test("an owner's Allow gives the app in one press, naming the Bot and the app an ); fireEvent.click(await view.findByRole("button", { name: /^Let .* Gmail$/ })); await waitFor(() => - expect(sent.filter((call) => call.method === "POST")).toEqual([ + expect(sent.filter((call) => call.method === "POST")).toStrictEqual([ { method: "POST", url: "/api/plugins/for/expenses/apps/composio-gmail/give", diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 83f13618c2..6f96f3038f 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -890,7 +890,7 @@ const ALLOW: Allowed[] = [ }, { file: "app/tests/connected-account-grants.test.tsx", - line: 124, + line: 126, shape: "e", finding: "NEW (round 2 did not find this)", why: "const siblingReads: string[] = [];", From efd8bb246496329017aea6cd3ac328dd783979d9 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:23:05 -0300 Subject: [PATCH 56/73] Add a changelog line for chart sources --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bcf0b573..ff2fb74230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +### Every chart says where its figures came from + +A chart a Bot draws now ends with a line naming what its figures were read from and when they are +from, such as "Sources: World Bank World Development Indicators · As of 2025". Figures a Bot charted +from its own knowledge, without looking them up, are marked "From the Bot's own knowledge. Not +checked against a source." Bots are told to look up figures that change over time before charting +them. Charts already in a conversation are drawn as before, with no such line. + ### Screens share one spacing, row and empty-state design Every configuration screen now takes its spacing from the page frame: 32px under the title, 48px From 6fad6e93c9567eb902503271425f7fcad40d3b2d Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:23:06 -0300 Subject: [PATCH 57/73] Add a changelog line for search routing --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bcf0b573..80c1d8e19a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +### A Bot searches with its search tool, never its browser + +Asked to search for something, a Bot could drive its browser to Google or another search engine +instead of using the search tool it held. With a search tool it now searches with that, and keeps +the browser for signing in, clicking through a site, filling in a form or opening a page the person +named. Without one it answers from what it knows, says it has not checked, and shows a card for a +search app (Parallel Search, Exa Search or Firecrawl) that the person can ask an administrator for. + ### Screens share one spacing, row and empty-state design Every configuration screen now takes its spacing from the page frame: 32px under the title, 48px From 1c931c8643c54aa0c77fa75f217ecddaa0868a31 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:23:06 -0300 Subject: [PATCH 58/73] Add a changelog line for the app card carrying on --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bcf0b573..5ae34840b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +### An app card carries the task on once the app is ready + +When a Bot stopped because it needed an app, and the person then connected it in another tab or an +administrator turned it on for the Bot, the card said "Ask again". It now sends "Gmail is ready now. +Please carry on." for the person, once, and the Bot picks the task back up; a card opened after the +change offers Continue instead. An administrator looking at the card gets the fix itself, "Turn on +and give it to Sales" or "Give it to Sales", which grants the app's read-only actions with the same +checks and trail as approving a request, or a link to the app's page where one press can't do it. + ### Screens share one spacing, row and empty-state design Every configuration screen now takes its spacing from the page frame: 32px under the title, 48px From b98019221f4a4fc5d1ace661b9ca4d81b2cd1a42 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:23:06 -0300 Subject: [PATCH 59/73] Add a changelog line for the handoff progress row --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bcf0b573..67cd8e16a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +### A conversation shows who it is waiting on + +When a Bot asks another Bot, the end of the conversation now reads "Asking Risk Analyst… 0:13", with +the clock running, until the answer comes back; a routine firing into it reads "Market Scout is +running a routine…". The sidebar keeps the conversation lit while it waits, and both survive a +reload. In a shared conversation only the person whose Bot asked sees whom it asked; the others see +only that it is busy. Migration `0063_work_items_open_thread` adds one partial index to `work_items`. + ### Screens share one spacing, row and empty-state design Every configuration screen now takes its spacing from the page frame: 32px under the title, 48px From aabae85cd029509df90cd477b7fe59f9af770fc4 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:23:06 -0300 Subject: [PATCH 60/73] Add a changelog line for the calm computer start --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bcf0b573..358a3d5d82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,19 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +### A computer that is starting says so, instead of showing a problem + +Opening the Computer panel while a Bot's computer was asleep or starting showed "Can't show the +screen right now", and an action during a cold start was marked "didn't work". The panel now says +the computer is starting, or is off until the Bot next uses it, and shows a problem only when it +can't be reached or has gone two and a half minutes without a picture; a live screen being +reconnected shows a small "Reconnecting…" badge. An action turned away by a cold start reads "not +done yet" and the Bot is told to try again. Looking at the panel no longer wakes a sleeping computer, +a Bot says in one sentence what it is about to do before its first computer action, and a hosted +computer's cold start answers within 45 seconds, under a load balancer's usual 60-second idle +timeout. A Docker computer still booting its browser reads as starting once the supervisor is +upgraded too; an older supervisor works as before. + ### Screens share one spacing, row and empty-state design Every configuration screen now takes its spacing from the page frame: 32px under the title, 48px From 70d76ea31f3931939511cb7bfd7c95d32cded06d Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:23:12 -0300 Subject: [PATCH 61/73] Add a changelog line for owner app grants --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae34840b9..1fa40a013b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,17 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +### A Bot's owner can give it their own apps + +The owner of a Bot can now let it use an app they connect for themselves, without an administrator: +from the app card ("Let Research Bot use your Gmail?"), under Your apps on the Bot's page, or on the +New Bot sheet, which offers their connected apps. Calls under such a grant always use the asking +person's own account, even on a published Team Bot, and an owner can take back only what they gave. +By default this covers apps each person connects for themselves and Routines; an administrator +decides per app on its page, and can switch it off for people with the "Give apps to their own Bots" +capability. Migration `0063_owner_app_grants` adds two nullable columns, `mcp_servers.owner_grants` +and `plugin_grants.granted_as`. + ### An app card carries the task on once the app is ready When a Bot stopped because it needed an app, and the person then connected it in another tab or an From 3848c8f37835b25ace7d272d635271c968c50388 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:47:59 -0300 Subject: [PATCH 62/73] Mark a Shared-account audience an owner's grant set, so it can be withdrawn with it --- server/drizzle/0064_owner_shared_audience.sql | 2 + server/drizzle/meta/0064_snapshot.json | 10214 ++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/shared-use.ts | 14 + 4 files changed, 10237 insertions(+) create mode 100644 server/drizzle/0064_owner_shared_audience.sql create mode 100644 server/drizzle/meta/0064_snapshot.json diff --git a/server/drizzle/0064_owner_shared_audience.sql b/server/drizzle/0064_owner_shared_audience.sql new file mode 100644 index 0000000000..dec812ad9b --- /dev/null +++ b/server/drizzle/0064_owner_shared_audience.sql @@ -0,0 +1,2 @@ +ALTER TABLE "shared_use_approvals" ADD COLUMN "approved_as" text;--> statement-breakpoint +ALTER TABLE "shared_use_approvals" ADD CONSTRAINT "shared_use_approvals_approved_as_check" CHECK ("shared_use_approvals"."approved_as" IS NULL OR "shared_use_approvals"."approved_as" IN ('admin', 'owner')); \ No newline at end of file diff --git a/server/drizzle/meta/0064_snapshot.json b/server/drizzle/meta/0064_snapshot.json new file mode 100644 index 0000000000..266f941e61 --- /dev/null +++ b/server/drizzle/meta/0064_snapshot.json @@ -0,0 +1,10214 @@ +{ + "id": "69984b21-7076-4157-887e-a7f06e2e3845", + "prevId": "59e401eb-8e91-4157-9ffd-c9e3aeb2dfab", + "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.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_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": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "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": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_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 + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_access_requests": { + "name": "app_access_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_key": { + "name": "app_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_title": { + "name": "app_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "app_access_requests_pending_idx": { + "name": "app_access_requests_pending_idx", + "columns": [ + { + "expression": "requested_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"agent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"app_access_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "app_access_requests_status_idx": { + "name": "app_access_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_access_requests_requested_by_users_id_fk": { + "name": "app_access_requests_requested_by_users_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "app_access_requests_agent_id_agents_id_fk": { + "name": "app_access_requests_agent_id_agents_id_fk", + "tableFrom": "app_access_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_access_requests_kind_check": { + "name": "app_access_requests_kind_check", + "value": "\"app_access_requests\".\"kind\" IN ('enable', 'grant', 'connect_shared')" + }, + "app_access_requests_status_check": { + "name": "app_access_requests_status_check", + "value": "\"app_access_requests\".\"status\" IN ('pending', 'approved', 'dismissed', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "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 + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_grants": { + "name": "owner_grants", + "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 + }, + "skipped_tools": { + "name": "skipped_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + }, + "mcp_servers_owner_grants_check": { + "name": "mcp_servers_owner_grants_check", + "value": "\"mcp_servers\".\"owner_grants\" IS NULL OR \"mcp_servers\".\"owner_grants\" IN ('any', 'admin')" + } + }, + "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 + }, + "vendor_name": { + "name": "vendor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_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 + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pending_authorizations_server_idx": { + "name": "pending_authorizations_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_authorizations_actor_id_users_id_fk": { + "name": "pending_authorizations_actor_id_users_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + }, + "pending_authorizations_completion_check": { + "name": "pending_authorizations_completion_check", + "value": "(\"pending_authorizations\".\"completed_at\" IS NULL) = (\"pending_authorizations\".\"outcome\" IS NULL)" + }, + "pending_authorizations_user_is_actor_check": { + "name": "pending_authorizations_user_is_actor_check", + "value": "\"pending_authorizations\".\"user_id\" IS NULL OR \"pending_authorizations\".\"user_id\" = \"pending_authorizations\".\"actor_id\"" + } + }, + "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 + }, + "granted_as": { + "name": "granted_as", + "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": { + "plugin_grants_granted_as_check": { + "name": "plugin_grants_granted_as_check", + "value": "\"plugin_grants\".\"granted_as\" IS NULL OR \"plugin_grants\".\"granted_as\" IN ('admin', 'owner')" + } + }, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_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": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "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()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "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": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "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": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_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": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_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()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "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": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "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": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "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.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "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()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_as": { + "name": "approved_as", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_approvals_approved_as_check": { + "name": "shared_use_approvals_approved_as_check", + "value": "\"shared_use_approvals\".\"approved_as\" IS NULL OR \"shared_use_approvals\".\"approved_as\" IN ('admin', 'owner')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 74a928ec93..98cee05d57 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -449,6 +449,13 @@ "when": 1793027892885, "tag": "0063_owner_app_grants", "breakpoints": true + }, + { + "idx": 64, + "version": "7", + "when": 1793033276066, + "tag": "0064_owner_shared_audience", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/shared-use.ts b/server/src/db/schema/shared-use.ts index 7660e9c2e9..05b241053c 100644 --- a/server/src/db/schema/shared-use.ts +++ b/server/src/db/schema/shared-use.ts @@ -31,6 +31,16 @@ export const sharedUseApprovals = pgTable( audience: text("audience").$type<"owner" | "people" | "team">().notNull(), outsideInput: boolean("outside_input").notNull(), approvedBy: text("approved_by").notNull(), + /** + * Who set this audience: an administrator (`admin`, or null from a replica older than this + * column), or the Bot's owner giving it a Shared app whose rule lets owners decide (`owner`). + * + * AN OWNER'S AUDIENCE LIVES ONLY AS LONG AS WHAT ALLOWED IT. It is withdrawn when the owner takes + * the app back or an administrator stops letting owners decide, and it is not read at all while + * the app's `owner_grants` is anything but `any`. An administrator's approval over it makes it + * theirs, and then it stays. + */ + approvedAs: text("approved_as").$type<"admin" | "owner">(), approvedAt: timestamp("approved_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -41,6 +51,10 @@ export const sharedUseApprovals = pgTable( "shared_use_approvals_audience_check", sql`${table.audience} IN ('owner', 'people', 'team')`, ), + check( + "shared_use_approvals_approved_as_check", + sql`${table.approvedAs} IS NULL OR ${table.approvedAs} IN ('admin', 'owner')`, + ), ], ); From 50ab1e39cd0c3b6c67ec30833d2c4b37406c4bdc Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:55:16 -0300 Subject: [PATCH 63/73] Let an owner's grant set a Shared app's audience, and read it only while owners decide --- server/src/audit.ts | 8 ++ server/src/plugins/owner-grants.ts | 72 +++++++++++ server/src/plugins/plugin-store/app-access.ts | 4 +- server/src/plugins/shared-use-store.ts | 119 +++++++++++++++++- 4 files changed, 196 insertions(+), 7 deletions(-) diff --git a/server/src/audit.ts b/server/src/audit.ts index 44939b23be..e4edea83c2 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -268,6 +268,14 @@ export const auditEventTypes = [ "shared_use.approved", // An administrator answered a request to use a Shared app through a Bot. "shared_use.declined", + /* + * A Bot's owner gave it a Shared app whose rule lets owners decide, and that set who may use the + * team account through the Bot (`approved_as = owner`); and that audience withdrawn, because the + * owner took the app back or an administrator stopped letting owners decide. Target the app; + * payload `bot`, `server`, and the `audience` set or `why` it went. + */ + "shared_use.owner_approved", + "shared_use.owner_withdrawn", /* * A person asking an administrator to switch a work app on, grant it to a Bot, or connect a * Shared app's team account (`app_access_requests`), and what became of it: cancelled by the diff --git a/server/src/plugins/owner-grants.ts b/server/src/plugins/owner-grants.ts index 4852f4ac38..2f744b41a7 100644 --- a/server/src/plugins/owner-grants.ts +++ b/server/src/plugins/owner-grants.ts @@ -1,6 +1,7 @@ import { capabilityRefusal } from "../admin/capabilities"; import { enterpriseControls } from "../admin/controls"; import type { AppInspection } from "./plugin-store/app-access"; +import type { SharedUseStore } from "./shared-use-store"; import type { PluginStore } from "./store"; /** @@ -19,6 +20,13 @@ import type { PluginStore } from "./store"; * the asking person's own account, even on the owner's published Team Bot. See * `credentialActorFor`. Publishing a Bot must not hand teammates the owner's mailbox. * + * A SHARED APP IS THE ONE EXCEPTION TO "WHOSE ACCOUNT", and the two rules do not meet. A Shared app + * runs on the deployment's one team account whoever the credential actor is (`accountFor` never + * reads it for `shared`), so the owner marker cannot send it to a personal account; and a Personal + * app never reaches here as Shared, so the marker keeps it on the asker's own. What a Shared app + * needs instead is an audience, and where an administrator has let owners decide (`owner_grants = + * any`, never the default for Shared) the owner's grant sets it: see {@link approveOwnersSharedUse}. + * * And an administrator can switch the whole thing off for anybody through the `ownerApps` * capability, which every check here reads. */ @@ -57,3 +65,67 @@ export async function ownerGrantRefusal( return `Only an admin can give ${name} to a Bot.`; return null; } + +type OwnerSharedUseEvents = Pick; + +/** + * The Shared-account audience a Bot owner's grant sets, and its row in the trail: who gave it, to + * which Bot, on which app, and the audience. Nothing where the app is not Shared, its rule is not + * `any`, or the Bot already holds an approval; see `approveOwnerGrant`. + */ +export async function approveOwnersSharedUse( + events: OwnerSharedUseEvents, + sharedUse: Pick, + input: { botId: string; serverId: string; ownerId: string }, +): Promise { + if (!input.serverId) return; + const approval = await sharedUse.approveOwnerGrant({ + botId: input.botId, + serverId: input.serverId, + by: input.ownerId, + }); + if (!approval) return; + const appId = await sharedUse.appIdOf(input.serverId); + await events.recordAppAccessEvent({ + eventType: "shared_use.owner_approved", + targetId: appId, + actorUserId: input.ownerId, + payload: { + bot: input.botId, + server: appId, + audience: approval.audience, + members: approval.members, + outsideInput: approval.outsideInput, + }, + }); +} + +/** + * Withdraw the audiences owners' grants set at an app — one Bot's, when its owner takes the app + * back, or every Bot's, when an administrator stops letting owners decide — each with a row in the + * trail. An administrator's approval is never touched. + */ +export async function withdrawOwnersSharedUse( + events: OwnerSharedUseEvents, + sharedUse: Pick, + input: { + serverId: string; + botId?: string; + actorId: string; + why: "owner_took_back" | "owner_grants_changed"; + }, +): Promise { + const bots = await sharedUse.withdrawOwnerApprovals({ + serverId: input.serverId, + ...(input.botId ? { botId: input.botId } : {}), + }); + if (bots.length === 0) return; + const appId = await sharedUse.appIdOf(input.serverId); + for (const bot of bots) + await events.recordAppAccessEvent({ + eventType: "shared_use.owner_withdrawn", + targetId: appId, + actorUserId: input.actorId, + payload: { bot, server: appId, why: input.why }, + }); +} diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts index dc7a9435fd..f492886d76 100644 --- a/server/src/plugins/plugin-store/app-access.ts +++ b/server/src/plugins/plugin-store/app-access.ts @@ -1258,7 +1258,9 @@ export function appAccessMethods(context: StoreContext) { | "app_access.dismissed" | "app_access.refused" | "app_access.owner_granted" - | "app_access.owner_revoked"; + | "app_access.owner_revoked" + | "shared_use.owner_approved" + | "shared_use.owner_withdrawn"; }, ): Promise { await recordAuditEvent(auditStore, { diff --git a/server/src/plugins/shared-use-store.ts b/server/src/plugins/shared-use-store.ts index 8d794a15f4..59ef1f14c6 100644 --- a/server/src/plugins/shared-use-store.ts +++ b/server/src/plugins/shared-use-store.ts @@ -174,14 +174,17 @@ export function createSharedUseStore( return (await rowsOfApp(serverId)).appId; } - async function approvalFor( - botId: string, - anyServerId: string, - ): Promise { - const serverId = await appIdOf(anyServerId); + /** The approval row for this Bot at the app, whoever set it, and whether it may be read now. */ + async function storedApproval(botId: string, serverId: string) { const [row] = await database - .select() + .select({ + audience: sharedUseApprovals.audience, + outsideInput: sharedUseApprovals.outsideInput, + approvedAs: sharedUseApprovals.approvedAs, + ownersDecide: sql`coalesce(${mcpServers.ownerGrants} = 'any' and ${mcpServers.accountMode} = 'shared', false)`, + }) .from(sharedUseApprovals) + .leftJoin(mcpServers, eq(mcpServers.id, sharedUseApprovals.serverId)) .where( and( eq(sharedUseApprovals.agentId, botId), @@ -189,7 +192,36 @@ export function createSharedUseStore( ), ) .limit(1); + return row ?? null; + } + + /** + * Who set the audience this Bot holds at the app — `admin` (a null from an older replica is + * one), `owner`, or null where there is none. + */ + async function approvalSource( + botId: string, + anyServerId: string, + ): Promise<"admin" | "owner" | null> { + const row = await storedApproval(botId, await appIdOf(anyServerId)); if (!row) return null; + return row.approvedAs === "owner" ? "owner" : "admin"; + } + + /* + * AN OWNER'S AUDIENCE COUNTS ONLY WHILE OWNERS DECIDE. One set by an owner's grant is withdrawn + * when an administrator stops letting owners give the app, but a row that outlived that — a + * replica that switched the rule without withdrawing, a delete that failed — must not keep the + * team account open. So it is read as no approval unless the app's rule is `any` now. + */ + async function approvalFor( + botId: string, + anyServerId: string, + ): Promise { + const serverId = await appIdOf(anyServerId); + const row = await storedApproval(botId, serverId); + if (!row) return null; + if (row.approvedAs === "owner" && !row.ownersDecide) return null; const members = await database .select({ kind: sharedUseApprovalMembers.kind, @@ -214,8 +246,11 @@ export function createSharedUseStore( serverId: string; approval: SharedUseApproval; by: string; + /** Who is setting it. Only {@link approveOwnerGrant} says `owner`. */ + as?: "admin" | "owner"; }) { const serverId = await appIdOf(input.serverId); + const approvedAs = input.as ?? "admin"; await database.transaction(async (tx) => { await tx .insert(sharedUseApprovals) @@ -225,6 +260,7 @@ export function createSharedUseStore( audience: input.approval.audience, outsideInput: input.approval.outsideInput, approvedBy: input.by, + approvedAs, }) .onConflictDoUpdate({ target: [sharedUseApprovals.agentId, sharedUseApprovals.serverId], @@ -232,6 +268,7 @@ export function createSharedUseStore( audience: input.approval.audience, outsideInput: input.approval.outsideInput, approvedBy: input.by, + approvedAs, approvedAt: new Date(), }, }); @@ -300,6 +337,73 @@ export function createSharedUseStore( .where(inArray(sharedUseApprovals.serverId, ids)); } + /** + * A Bot's owner gave it a Shared app whose rule lets owners decide: the audience that grant sets, + * written and answered, or null where it sets none. + * + * WHO, EXACTLY THE PEOPLE WHO CAN USE THE BOT NOW. The Bot's exposure as it stands — its owner + * for a private Bot, its published audience for a Team Bot — and never outside input: an email + * or a webhook steering the team account is not somebody who can use the Bot, and stays an + * administrator's to approve. It is a snapshot, as an administrator's approval is: publishing the + * Bot further later is caught by the gate and asked of an administrator, the way every widening is. + * + * NEVER OVER AN APPROVAL ALREADY THERE. An administrator's stands for a reason the owner did not + * give, narrower or wider, and the owner's own from before is already this. Nothing is written + * unless the app's answering row is Shared and its rule is `any` now, read here rather than + * trusted from the caller. + */ + async function approveOwnerGrant(input: { + botId: string; + serverId: string; + by: string; + }): Promise { + const serverId = await appIdOf(input.serverId); + const [app] = await database + .select({ + accountMode: mcpServers.accountMode, + ownerGrants: mcpServers.ownerGrants, + }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + if (app?.accountMode !== "shared" || app.ownerGrants !== "any") return null; + if (await storedApproval(input.botId, serverId)) return null; + const approval: SharedUseApproval = { + ...exposureOf(await botFacts(input.botId)), + outsideInput: false, + }; + await setApproval({ + botId: input.botId, + serverId, + approval, + by: input.by, + as: "owner", + }); + return approval; + } + + /** + * Withdraw the audiences owners' grants set at an app — for one Bot, or every Bot — and none an + * administrator set. Answers the Bots withdrawn from, so each can be written to the trail. + */ + async function withdrawOwnerApprovals(input: { + serverId: string; + botId?: string; + }): Promise { + const { ids } = await rowsOfApp(input.serverId); + const withdrawn = await database + .delete(sharedUseApprovals) + .where( + and( + inArray(sharedUseApprovals.serverId, ids), + eq(sharedUseApprovals.approvedAs, "owner"), + ...(input.botId ? [eq(sharedUseApprovals.agentId, input.botId)] : []), + ), + ) + .returning({ botId: sharedUseApprovals.agentId }); + return [...new Set(withdrawn.map((row) => row.botId))]; + } + async function sourcesOf(responsibilityId: string): Promise { const [goal] = await database .select({ subscriptions: responsibilities.subscriptions }) @@ -703,8 +807,11 @@ export function createSharedUseStore( return { appIdOf, approvalFor, + approvalSource, admitsPerson, setApproval, + approveOwnerGrant, + withdrawOwnerApprovals, deleteApprovalsFor, botFacts, actorFacts, From 4b6577d0fb363cb19c8be46b63e1d310e21e64af Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:55:16 -0300 Subject: [PATCH 64/73] Set the audience when an owner gives a Shared app, and withdraw it on take-back or a rule change --- server/src/plugins/route-groups/app-access.ts | 50 ++++++++++++++++++- server/src/plugins/route-groups/grants.ts | 21 ++++++-- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts index 14d32d3850..673aa4f51a 100644 --- a/server/src/plugins/route-groups/app-access.ts +++ b/server/src/plugins/route-groups/app-access.ts @@ -8,7 +8,12 @@ import type { Unavailable, } from "../app-access"; import type { CatalogueEntry } from "../catalogue"; -import { ownerAppsAllowed, ownerGrantRefusal } from "../owner-grants"; +import { + approveOwnersSharedUse, + ownerAppsAllowed, + ownerGrantRefusal, + withdrawOwnersSharedUse, +} from "../owner-grants"; import type { AppAccessRequestRecord, AppDirectory, @@ -687,8 +692,25 @@ export function mountAppAccessRoutes({ inspected, ); if (refusal) return deny("owner_grant_refused", refusal, 403); - if (access.granted) return context.json({ granted: [] }); const serverId = inspected.answeringId ?? ""; + /* + * A SHARED APP OWNERS MAY GIVE IS USABLE AT ONCE: the grant sets who may use the team account + * through this Bot, the people who can use the Bot, and nobody else (`approveOwnerGrant`). Asked + * again of a Bot that already holds the owner's grant too, so pressing give puts back an + * audience withdrawn while an administrator had owners' choice switched off. + */ + const approveShared = async () => { + if (shared) + await approveOwnersSharedUse(store, shared.use, { + botId: agentId, + serverId, + ownerId: context.var.actor.id, + }); + }; + if (access.granted) { + if (access.givenByOwner) await approveShared(); + return context.json({ granted: [] }); + } const tools = await store.appTools(serverId); if (tools.length === 0) return deny( @@ -714,6 +736,7 @@ export function mountAppAccessRoutes({ actorUserId: context.var.actor.id, payload: { app: access.key, bot: agentId, refs }, }); + await approveShared(); return context.json({ granted: refs }); }); @@ -757,6 +780,17 @@ export function mountAppAccessRoutes({ actorUserId: context.var.actor.id, payload: { app: inspected.access.key, bot: agentId, refs }, }); + /* + * The Shared-account audience the owner's grant set goes with it. One an administrator + * approved stays, as their grants do. + */ + if (shared && inspected.answeringId) + await withdrawOwnersSharedUse(store, shared.use, { + serverId: inspected.answeringId, + botId: agentId, + actorId: context.var.actor.id, + why: "owner_took_back", + }); return context.json({ revoked: refs }); }, ); @@ -799,6 +833,18 @@ export function mountAppAccessRoutes({ }); if (!changed) return context.json({ error: "That app hasn't been added." }, 404); + /* + * Owners no longer decide, so the Shared-account audiences their grants set are withdrawn. + * The grants stay, an administrator's to keep or take, and their calls are refused until an + * administrator approves who may use the account. (`approvalFor` would not read those audiences + * now anyway; this removes them rather than leaving them to come back if the rule does.) + */ + if (value !== "any" && shared) + await withdrawOwnersSharedUse(store, shared.use, { + serverId: context.req.param("id"), + actorId: context.var.actor.id, + why: "owner_grants_changed", + }); return context.json({ ok: true }); }); diff --git a/server/src/plugins/route-groups/grants.ts b/server/src/plugins/route-groups/grants.ts index 7570622118..7fb5d0d2a1 100644 --- a/server/src/plugins/route-groups/grants.ts +++ b/server/src/plugins/route-groups/grants.ts @@ -5,7 +5,7 @@ import { exposureOf, type SharedUseApproval, } from "../shared-use"; -import { ownerGrantRefusal } from "../owner-grants"; +import { approveOwnersSharedUse, ownerGrantRefusal } from "../owner-grants"; import { CatalogueEntryUnknownError, isDeploymentFault, @@ -20,7 +20,8 @@ import type { PluginRouteContext } from "./context"; * * Without an approval the gate refuses every call the grant just allowed, so a grant with none sent * approves the Bot's exposure as it stands now — unless an approval is already recorded, which is - * an administrator's earlier decision and not this grant's to widen. "Is Shared" is the app's + * an administrator's earlier decision and not this grant's to widen (one an owner's grant set is not + * that, and is replaced). "Is Shared" is the app's * answer (its answering row), never the granted row's own column. Null `approval` is nothing to * record; `unreadable` is an approval sent that this deployment cannot read, which the caller * refuses. @@ -46,7 +47,11 @@ export async function sharedGrantApproval( const approval = asSharedUseApproval(sent); return approval ? { appId, approval } : "unreadable"; } - if (await shared.use.approvalFor(agentId, appId)) + /* + * An audience an owner's grant set is not an administrator's decision, so an administrator + * granting over it approves the Bot's exposure now, which makes the audience theirs. + */ + if ((await shared.use.approvalSource(agentId, appId)) === "admin") return { appId, approval: null }; return { appId, @@ -339,8 +344,8 @@ export function mountGrantRoutes({ * {@link sharedGrantApproval} for what is recorded when none is sent. */ /* - * An owner's grant records no Shared approval: who may reach a team account through a Bot is an - * administrator's decision, and the shared-use gate refuses the calls and files their request. + * An owner's grant sends no approval of its own. Where the app is Shared and an administrator + * lets owners decide, the grant sets the audience itself, below; see `approveOwnersSharedUse`. */ const asOwner = kind === "mcp" && !skillActor(context).isAdmin; const decided = @@ -373,6 +378,12 @@ export function mountGrantRoutes({ by: context.var.actor.id, }); } + if (asOwner && shared) + await approveOwnersSharedUse(store, shared.use, { + botId: grantAgentId, + serverId: grantRef.split("/")[0] ?? "", + ownerId: context.var.actor.id, + }); return context.json({ ok: true }); }); From 4333122f065c3597874a43db1b54ab4a2216bc7c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:55:16 -0300 Subject: [PATCH 65/73] Test an owner's Shared-app grant: usable at once, scoped to the Bot, withdrawn with the grant or the rule --- .../owner-app-grants.integration.test.ts | 246 +++++++++++++++++- 1 file changed, 243 insertions(+), 3 deletions(-) diff --git a/server/tests/owner-app-grants.integration.test.ts b/server/tests/owner-app-grants.integration.test.ts index 034ffcb45c..cbae0bfba2 100644 --- a/server/tests/owner-app-grants.integration.test.ts +++ b/server/tests/owner-app-grants.integration.test.ts @@ -13,7 +13,11 @@ import { and, eq, inArray, like } from "drizzle-orm"; import { Hono, type MiddlewareHandler } from "hono"; import { createAgentProfileStore } from "../src/agents/profile-store"; import type { AgentActor } from "../src/agents/profile-types"; -import type { AuditEventInput, AuditStore } from "../src/audit"; +import { + type AuditEventInput, + type AuditStore, + PERSON_INITIATOR, +} from "../src/audit"; import type { AppVariables } from "../src/auth/guards"; import { createDatabase } from "../src/db/client"; import { @@ -23,6 +27,7 @@ import { mcpServers, mcpTools, pluginGrants, + sharedUseApprovals, teamBotConsents, teamBotPublications, users, @@ -30,7 +35,10 @@ import { import type { AppAccess } from "../src/plugins/app-access"; import type { GiveableApp } from "../src/plugins/plugin-store/app-access"; import { createPluginRoutes } from "../src/plugins/routes"; -import { createSharedUseStore } from "../src/plugins/shared-use-store"; +import { + createSharedUseGate, + createSharedUseStore, +} from "../src/plugins/shared-use-store"; import { createPluginStore, PluginRefusedError } from "../src/plugins/store"; import { createTeamBots, @@ -107,6 +115,8 @@ async function connectedServers(userId: string): Promise> { return new Set(rows.map((row) => `composio-${row.app}`)); } const teamBots = createTeamBots({ database, connectedServers }); +const sharedUse = createSharedUseStore(database, "composio"); +const sharedGate = createSharedUseGate(sharedUse, audit); function appAs(actorId: string, role: "admin" | "user" = "user") { const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( @@ -128,7 +138,7 @@ function appAs(actorId: string, role: "admin" | "user" = "user") { async () => true, undefined, { broker: stub.broker, provider: "composio" }, - { modes: {} as never, use: createSharedUseStore(database, "composio") }, + { modes: {} as never, use: sharedUse }, ), ); } @@ -475,3 +485,233 @@ describe("a Team Bot whose owner gave it their own Gmail", () => { }); }); }); + +describe("a Shared app whose rule lets Bot owners decide", () => { + /** The admin opens Slack, a Shared app with its team account connected, to owners. */ + async function openSlack() { + await seedApp(SLACK, "Slack", "shared"); + await database.insert(brokeredConnections).values({ + provider: "composio", + app: slug("slack"), + holder: "deployment", + userId: null, + vendorUserId: `openbot-deployment:${suite}`, + verified: true, + }); + const opened = await appAs(admin, "admin").request( + `/api/plugins/servers/${SLACK}/owner-grants`, + send("PUT", { ownerGrants: "any" }), + ); + expect(opened.status).toBe(200); + } + + const give = (botId: string) => + appAs(owner).request( + `/api/plugins/for/${botId}/apps/${SLACK}/give`, + send("POST"), + ); + + /** Whether this person's own chat with the Bot may reach the team account now. */ + const allowed = async (botId: string, actorId: string) => + ( + await sharedGate({ + botId, + serverId: SLACK, + title: "Slack", + actorId, + initiator: PERSON_INITIATOR, + }) + ).allowed; + + async function approvalRow(botId: string) { + const [row] = await database + .select({ + audience: sharedUseApprovals.audience, + outsideInput: sharedUseApprovals.outsideInput, + approvedBy: sharedUseApprovals.approvedBy, + approvedAs: sharedUseApprovals.approvedAs, + }) + .from(sharedUseApprovals) + .where(eq(sharedUseApprovals.agentId, botId)); + return row ?? null; + } + + const publishToTeam = (botId: string) => + teamBots.publish({ id: owner, role: "user" }, botId, { + audience: "team", + emails: [], + groups: [], + }); + + test("the owner's grant on a private Bot is usable at once, by the owner only, with no admin approval", async () => { + await openSlack(); + const bot = await botOf(owner); + expect(await allowed(bot, owner)).toBe(false); + + expect((await give(bot)).status).toBe(200); + expect(await approvalRow(bot)).toEqual({ + audience: "owner", + outsideInput: false, + approvedBy: owner, + approvedAs: "owner", + }); + expect(await allowed(bot, owner)).toBe(true); + expect(await allowed(bot, teammate)).toBe(false); + expect(await stateOf(owner, bot, SLACK)).toMatchObject({ + state: "ready", + givenByOwner: true, + }); + expect( + events.find((event) => event.eventType === "shared_use.owner_approved"), + ).toMatchObject({ + actorUserId: owner, + targetId: SLACK, + payload: { bot, server: SLACK, audience: "owner", outsideInput: false }, + }); + }); + + test("on a published Team Bot it reaches the Bot's audience, as it stood when given, and no further", async () => { + await openSlack(); + const team = await botOf(owner); + await publishToTeam(team); + expect((await give(team)).status).toBe(200); + expect(await approvalRow(team)).toMatchObject({ + audience: "team", + outsideInput: false, + approvedAs: "owner", + }); + expect(await allowed(team, teammate)).toBe(true); + expect(await stateOf(teammate, team, SLACK)).toMatchObject({ + state: "ready", + }); + // The account is the team's whoever asks: the owner marker never points it at a person. + expect( + await teamBots.credentialActorFor(teammate, team)(`${SLACK}/FETCH`), + ).toBe(teammate); + + // Published after the grant: the audience the grant set is the owner's, and widening asks an admin. + const later = await botOf(owner); + expect((await give(later)).status).toBe(200); + await publishToTeam(later); + expect(await allowed(later, owner)).toBe(true); + expect(await allowed(later, teammate)).toBe(false); + }); + + test("taking the app back withdraws the audience, and an admin's approval stays", async () => { + await openSlack(); + const bot = await botOf(owner); + expect((await give(bot)).status).toBe(200); + const taken = await appAs(owner).request( + `/api/plugins/for/${bot}/apps/${SLACK}/give`, + send("DELETE"), + ); + expect(await taken.json()).toEqual({ revoked: [`${SLACK}/FETCH`] }); + expect(await approvalRow(bot)).toBeNull(); + expect(await allowed(bot, owner)).toBe(false); + expect( + events.find((event) => event.eventType === "shared_use.owner_withdrawn"), + ).toMatchObject({ + actorUserId: owner, + payload: { bot, server: SLACK, why: "owner_took_back" }, + }); + + // An admin granting over the owner's audience makes it theirs, and the owner's take-back leaves it. + expect((await give(bot)).status).toBe(200); + const byAdmin = await appAs(admin, "admin").request( + "/api/plugins/grants", + send("POST", { kind: "mcp", ref: `${SLACK}/SEND`, agentId: bot }), + ); + expect(byAdmin.status).toBe(200); + expect(await approvalRow(bot)).toMatchObject({ approvedAs: "admin" }); + await appAs(owner).request( + `/api/plugins/for/${bot}/apps/${SLACK}/give`, + send("DELETE"), + ); + expect(await approvalRow(bot)).toMatchObject({ approvedAs: "admin" }); + expect(await allowed(bot, owner)).toBe(true); + }); + + test("an admin switching the rule back to admin withdraws the audience and refuses the calls", async () => { + await openSlack(); + const bot = await botOf(owner); + const other = await botOf(owner); + expect((await give(bot)).status).toBe(200); + // An admin's own approval on another Bot is not the owner's to lose. + await store.grant("mcp", `${SLACK}/FETCH`, other, "admin@example.test"); + await sharedUse.setApproval({ + botId: other, + serverId: SLACK, + approval: { audience: "owner", outsideInput: false, members: [] }, + by: admin, + }); + + const closed = await appAs(admin, "admin").request( + `/api/plugins/servers/${SLACK}/owner-grants`, + send("PUT", { ownerGrants: "admin" }), + ); + expect(closed.status).toBe(200); + expect(await approvalRow(bot)).toBeNull(); + expect(await allowed(bot, owner)).toBe(false); + expect(await approvalRow(other)).toMatchObject({ approvedAs: "admin" }); + expect( + events.find((event) => event.eventType === "shared_use.owner_withdrawn"), + ).toMatchObject({ + actorUserId: admin, + payload: { bot, server: SLACK, why: "owner_grants_changed" }, + }); + + // Opened again, the owner's press puts it back; their grant was never taken. + await appAs(admin, "admin").request( + `/api/plugins/servers/${SLACK}/owner-grants`, + send("PUT", { ownerGrants: "any" }), + ); + expect(await allowed(bot, owner)).toBe(false); + expect(await (await give(bot)).json()).toEqual({ granted: [] }); + expect(await allowed(bot, owner)).toBe(true); + }); + + test("an owner's audience left behind is not read while the rule is not any", async () => { + await openSlack(); + const bot = await botOf(owner); + expect((await give(bot)).status).toBe(200); + // The rule changed without the withdrawal, as a replica from before this change would. + await database + .update(mcpServers) + .set({ ownerGrants: null }) + .where(eq(mcpServers.id, SLACK)); + expect(await approvalRow(bot)).not.toBeNull(); + expect(await allowed(bot, owner)).toBe(false); + }); + + test("a Personal app's owner grant sets no Shared audience", async () => { + const bot = await botOf(owner); + await connect(owner); + expect( + ( + await appAs(owner).request( + `/api/plugins/for/${bot}/apps/${GMAIL}/give`, + send("POST"), + ) + ).status, + ).toBe(200); + expect(await approvalRow(bot)).toBeNull(); + expect( + events.some((event) => event.eventType === "shared_use.owner_approved"), + ).toBe(false); + }); + + test("an owner adding one action through the grant route sets the same audience", async () => { + await openSlack(); + const bot = await botOf(owner); + const added = await appAs(owner).request( + "/api/plugins/grants", + send("POST", { kind: "mcp", ref: `${SLACK}/SEND`, agentId: bot }), + ); + expect(added.status).toBe(200); + expect(await approvalRow(bot)).toMatchObject({ + audience: "owner", + approvedAs: "owner", + }); + expect(await allowed(bot, owner)).toBe(true); + }); +}); From f15712eb538bcc0473da896b097b75bf981115a1 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:55:16 -0300 Subject: [PATCH 66/73] Say on a Shared app's page that owners who give it let their Bots use the team account --- app/src/routes/_authed/admin/plugins/$key.tsx | 11 +- app/tests/admin-owner-grants-line.test.tsx | 178 ++++++++++++++++++ 2 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 app/tests/admin-owner-grants-line.test.tsx diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index c86836471b..01f7b91858 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -648,7 +648,8 @@ function RouteComponent() { * Who may give the app to a Bot. Binary and immediate, so a Switch. Every person's own * account and the built-in Routines start on; a deployment token, a shared account and an * app with no account start off, because a Bot given one of those reaches an account that - * is not the asker's. An owner's grant always runs on the asker's own account. + * is not the asker's. An owner's grant runs on the asker's own account, except on a shared + * account, where the grant lets the people who can use the Bot use the team account. */} {server ? ( <> @@ -657,9 +658,11 @@ function RouteComponent() { Bot owners can give it to their Bots - {server.ownerGrants === "any" - ? "People can let Bots they own use it, on the account of whoever is asking." - : "Only an admin can give it to a Bot."} + {server.ownerGrants !== "any" + ? "Only an admin can give it to a Bot." + : shared + ? "Bot owners who give it to their Bots let those Bots use the team account." + : "People can let Bots they own use it, on the account of whoever is asking."} diff --git a/app/tests/admin-owner-grants-line.test.tsx b/app/tests/admin-owner-grants-line.test.tsx new file mode 100644 index 0000000000..615288f7f6 --- /dev/null +++ b/app/tests/admin-owner-grants-line.test.tsx @@ -0,0 +1,178 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render } from "@testing-library/react"; +import type { PluginServer, PluginsPage } from "@/lib/plugins/queries"; +import { Route as AdminPluginRoute } from "@/routes/_authed/admin/plugins/$key"; + +/** + * The admin app page's "Bot owners can give it to their Bots" line says what letting owners decide + * means for the app's account: on a shared account, an owner's grant lets their Bot use the team + * account. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + await GlobalRegistrator.unregister(); +}); + +const APP = "gmail"; + +function captureRouteState(route: object): Record { + return { ...route, options: { ...(route as { options: object }).options } }; +} +function restoreRouteState( + route: object, + snapshot: Record, +): void { + for (const key of Object.keys(route)) { + if (!(key in snapshot)) { + delete (route as Record)[key]; + } + } + Object.assign(route, snapshot); +} +const pristine = captureRouteState(AdminPluginRoute); +let snapshot: Record; +beforeEach(() => { + snapshot = captureRouteState(pristine); +}); +afterEach(() => { + restoreRouteState(AdminPluginRoute, snapshot); +}); + +function server(shared: boolean, ownerGrants: "any" | "admin"): PluginServer { + return { + authScheme: "OAUTH2", + id: APP, + title: "Gmail", + vendor: "Google", + url: "https://example.invalid", + summary: "Mail.", + docsUrl: "", + provenance: "composio", + hasCredential: false, + toolsRefreshedAt: null, + lastError: null, + addedBy: null, + dynamicClient: false, + accountMode: shared ? "shared" : null, + sharedVendorConnected: false, + ownerGrants, + ownerGrantsChosen: null, + tools: [], + withdrawn: [], + }; +} + +function install(setup: { shared: boolean; ownerGrants: "any" | "admin" }) { + const page: PluginsPage = { + catalogue: [], + servers: [server(setup.shared, setup.ownerGrants)], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured: true, + authorizesPerAction: false, + provider: "composio", + }; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); + global.fetch = (async (input: RequestInfo | URL) => { + const path = String(input).split("?")[0] ?? ""; + if (path === "/api/plugins") return json(page); + if (path.startsWith("/api/plugins/connections")) + return json({ connections: [], redirectUri: null }); + if (path.startsWith("/api/agents")) return json([]); + return json({ error: "unexpected" }, 404); + }) as typeof fetch; +} + +function renderScreen() { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const adminRoute = createRoute({ + path: "/admin", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const indexRoute = createRoute({ + path: "/plugins/", + getParentRoute: () => adminRoute, + component: () => null, + }); + const wired = ( + AdminPluginRoute as unknown as { + update: (options: unknown) => typeof AdminPluginRoute; + } + ).update({ + id: "/plugins/$key", + path: "/plugins/$key", + getParentRoute: () => adminRoute, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authedRoute.addChildren([adminRoute.addChildren([indexRoute, wired])]), + ]), + history: createMemoryHistory({ initialEntries: [`/admin/plugins/${APP}`] }), + }); + return render( + + + , + ); +} + +const cases = [ + { + shared: true, + ownerGrants: "any", + line: "Bot owners who give it to their Bots let those Bots use the team account.", + }, + { + shared: false, + ownerGrants: "any", + line: "People can let Bots they own use it, on the account of whoever is asking.", + }, + { + shared: true, + ownerGrants: "admin", + line: "Only an admin can give it to a Bot.", + }, +] as const; + +for (const { shared, ownerGrants, line } of cases) { + test(`a ${shared ? "shared" : "personal"} app whose rule is ${ownerGrants} says: ${line}`, async () => { + install({ shared, ownerGrants }); + const view = renderScreen(); + expect(await view.findByText(line)).toBeTruthy(); + }); +} From dc9690aa55260fbc3bf573a99db065feb2a588b5 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 13:55:16 -0300 Subject: [PATCH 67/73] Document owners giving Shared apps, and add the changelog line --- CHANGELOG.md | 6 ++++++ docs/plugins/composio.md | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa40a013b..f986ef5b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,12 @@ decides per app on its page, and can switch it off for people with the "Give app capability. Migration `0063_owner_app_grants` adds two nullable columns, `mcp_servers.owner_grants` and `plugin_grants.granted_as`. +Where an administrator lets owners give a Shared app, an owner's grant works straight away: it lets +the people who can use the Bot (just the owner, or a Team Bot's audience) use the team account, with +no separate approval. Writes still ask first. Taking the app back, or the administrator switching the +app back to admins only, withdraws that. Migration `0064_owner_shared_audience` adds the nullable +`shared_use_approvals.approved_as`. + ### An app card carries the task on once the app is ready When a Bot stopped because it needed an app, and the person then connected it in another tab or an diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md index 97b9a29f10..cb4db23773 100644 --- a/docs/plugins/composio.md +++ b/docs/plugins/composio.md @@ -429,6 +429,15 @@ them), or everyone — and separately, whether outside input (email, Slack, webh than a schedule or Run now) may. Every call is checked against that, including runs handed on from another Bot, which are judged by what started the first run. +**Owners can decide, if an administrator lets them.** By default only an administrator gives a +Shared app to a Bot. If the app's page says Bot owners can give it to their Bots, an owner who gives +it to a Bot they own also sets who may use the account through that Bot: the people who can use the +Bot when they give it (only the owner for a private Bot, its published audience for a Team Bot), +and never outside input. No separate approval is needed. The trail records it as +`shared_use.owner_approved`, naming the owner, the Bot and the app. Taking the app back, or the +administrator switching the app back to admins only, withdraws that audience +(`shared_use.owner_withdrawn`); an audience an administrator approved stays. + **Widening a Bot later** — publishing it further, assigning it to a group, making it public, adding a trigger — goes through. If an administrator made the change, it is approved in the same step. If the owner did, the screen says the shared calls will be refused and offers Request approval, and a call From cd006f45a1739c4771509028d572752883aa1305 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 14:56:24 -0300 Subject: [PATCH 68/73] Say where a chart's figures came from in Slack and Teams too --- CHANGELOG.md | 3 +- server/src/delivery/opentag-agent.ts | 4 +- server/src/delivery/opentag.ts | 32 ++++++++++++++ server/tests/delivery-routes.test.ts | 65 ++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2fb74230..9b5df96af4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,8 @@ A chart a Bot draws now ends with a line naming what its figures were read from from, such as "Sources: World Bank World Development Indicators · As of 2025". Figures a Bot charted from its own knowledge, without looking them up, are marked "From the Bot's own knowledge. Not checked against a source." Bots are told to look up figures that change over time before charting -them. Charts already in a conversation are drawn as before, with no such line. +them. In Slack and Teams the same line follows the chart in the reply. Charts already in a +conversation are drawn as before, with no such line. ### Screens share one spacing, row and empty-state design diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 50ee2e7bc3..a4340c6f7a 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -7,18 +7,19 @@ * runs. */ import { randomUUID } from "node:crypto"; +import type { DrawnComponent } from "@openbot/toolkit/run-turn"; import { z } from "zod"; import { type ApprovalDecision, approvalPreview } from "../approvals/types"; import { ingestSlackEvent, type SlackTriggerEvent, } from "../responsibilities/slack"; -import type { DrawnComponent } from "@openbot/toolkit/run-turn"; import { agUiStream, approvalFields, COMPONENT_CALL_PREFIX, chartForOpenTag, + chartSourcesLine, componentCallEvents, confirmWriteInterrupt, isComponentFollowUp, @@ -208,6 +209,7 @@ export async function handleOpenTagRun( args, )) emit(event); + say(chartSourcesLine(component.args)); } if (outcome.interrupt) emit({ diff --git a/server/src/delivery/opentag.ts b/server/src/delivery/opentag.ts index 05a62d5fd0..7ca63ca031 100644 --- a/server/src/delivery/opentag.ts +++ b/server/src/delivery/opentag.ts @@ -555,6 +555,38 @@ export function chartForOpenTag(component: { return null; } +/** + * The line said under a chart drawn natively, saying where its figures came from, or "" when the + * Bot said nothing about that. OpenTag's chart has no caption, so without it a Slack or Teams chart + * would lose what the same chart says in OpenBot. Mirrors `Sources` in the app's gallery charts. + */ +export function chartSourcesLine(args: Record): string { + const seen = new Set(); + const named: string[] = []; + for (const entry of Array.isArray(args.sources) ? args.sources : []) { + const title = typeof entry?.title === "string" ? entry.title.trim() : ""; + if (!title) continue; + const url = + typeof entry.url === "string" && /^https?:\/\//i.test(entry.url) + ? entry.url + : undefined; + const key = `${title}|${url ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + named.push(url ? `[${title}](${url})` : title); + } + const asOf = typeof args.asOf === "string" ? args.asOf.trim() : ""; + return [ + args.basis === "own-knowledge" + ? "From the Bot's own knowledge. Not checked against a source." + : "", + named.length ? `Sources: ${named.join(", ")}` : "", + asOf ? `As of ${asOf}` : "", + ] + .filter(Boolean) + .join(" · "); +} + /** A component call for the Channel to draw, as AG-UI tool-call events. */ export function componentCallEvents( toolCallId: string, diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index 3602a981fe..758cc972cd 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -782,6 +782,71 @@ test("a chart the Bot drew is drawn natively by the Channel, and its follow-up r f.calls.filter((c) => (c as { converse?: unknown }).converse), ).toHaveLength(turns); }); +test("a chart drawn natively in Slack or Teams says where its figures came from", async () => { + const points = [{ label: "France", value: 3.1 }]; + const f = fixture({ + converse: { + kind: "replied", + binding: bound, + inboxId: "in1", + replyText: "Here is GDP.", + components: [ + { + name: "showBarChart", + args: { + title: "GDP", + points, + basis: "read", + sources: [ + { title: "World Bank", url: "https://data.worldbank.org" }, + { title: "World Bank", url: "https://data.worldbank.org" }, + { title: "Notes", url: "javascript:alert(1)" }, + null, + ], + asOf: "2025", + }, + }, + { + name: "showPieChart", + args: { title: "Unchecked", points, basis: "own-knowledge" }, + }, + { name: "showBarChart", args: { title: "Old", points } }, + ], + extras: { approvals: [], questions: [] }, + }, + }); + const result = await run(f, { + tools: [{ name: "render_chart", description: "", parameters: {} }], + context: [sender()], + messages: [{ id: "m1", role: "user", content: "chart GDP" }], + }); + expect( + result.events.filter((e) => e.type === "TOOL_CALL_START"), + ).toHaveLength(3); + expect( + result.events + .filter((e) => e.type === "TEXT_MESSAGE_CONTENT") + .map((e) => e.delta), + ).toEqual([ + "Here is GDP.", + "Sources: [World Bank](https://data.worldbank.org), Notes · As of 2025", + "From the Bot's own knowledge. Not checked against a source.", + ]); + // Each line follows its own chart. + const kinds = result.events + .filter( + (e) => e.type === "TOOL_CALL_START" || e.type === "TEXT_MESSAGE_CONTENT", + ) + .map((e) => e.type); + expect(kinds).toEqual([ + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_START", + ]); +}); test("a channel message from a colleague who never linked still reaches the owners' Slack triggers", async () => { // Whose triggers fire is decided by each owner's channel membership in the ingest, never by the // author's own link: the author here has no OpenBot binding at all. From a7030b02686c18cb0ae082a2fae5beb5dc5a460f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 14:59:53 -0300 Subject: [PATCH 69/73] Give each Bot its own files on a computer many Bots share --- CHANGELOG.md | 14 ++++- agent-computer/src/index.ts | 35 ++++++----- agent-computer/src/workspaces.ts | 60 +++++++++++++++++++ agent-computer/tests/workspaces.test.ts | 50 ++++++++++++++++ .../templates/computer/statefulset.yaml | 6 ++ charts/openbot/values.yaml | 3 +- docker-compose.yml | 3 + docker/s6/s6-rc.d/computer/run | 1 + docs/configuration.md | 1 + 9 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 agent-computer/src/workspaces.ts create mode 100644 agent-computer/tests/workspaces.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bcf0b573..851be0dc95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -**Before upgrading.** Eight things change for an existing deployment: +**Before upgrading.** Nine things change for an existing deployment: - A hosted computer's browser is now headed by default. It uses a little more memory than headless did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old behaviour. @@ -39,6 +39,18 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. research run replaces them. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +- On Helm's `shared` computer and the docker-compose `agent-computer`, each Bot's files now live in + `computers/` on the workspace volume (below). Files saved before the upgrade stay at the + root of the volume, where no Bot sees them; move them into a Bot's folder to give them back. + +### Each Bot on a shared computer has its own files + +A computer that serves many Bots kept one folder of files for all of them, so a note one Bot saved +was a file every other Bot could list and read. Each Bot now gets its own folder, and its commands +start there. Logins were already kept apart. This is on for Helm's `shared` computer and the +docker-compose `agent-computer`, and `COMPUTER_SEPARATE_WORKSPACES=on` turns it on for the +all-in-one image. A command can still read another Bot's folder, so a deployment that needs people +kept apart should give each Bot a computer of its own (`computers.mode: sandbox`). ### Screens share one spacing, row and empty-state design diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index 0848640259..b6640d13e8 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -27,8 +27,8 @@ import { TAKE_CONTROL_FIRST, } from "./control"; import { describeHumanGesture } from "./demonstration"; -import { enterText, FieldValueError } from "./enter-text"; import { handleEgressPolicyRequest, startEgressFilter } from "./egress"; +import { enterText, FieldValueError } from "./enter-text"; import { identity } from "./identity"; import { assertPageAccess, @@ -51,7 +51,6 @@ import { sensitiveRefs, } from "./secret-masking"; import { type BotSession, createSessions } from "./sessions"; -import { createShell } from "./shell"; import { fillSignIn, fillSignInCode, @@ -61,12 +60,12 @@ import { import { startVirtualDisplay } from "./virtual-display"; import { applyWebBotAuthPush } from "./web-bot-auth"; import { - createWorkspace, WorkspaceFileError, WorkspaceFileNotFoundError, WorkspaceFileTooLargeError, WorkspacePathError, } from "./workspace"; +import { createWorkspaces } from "./workspaces"; /** * The Bot's computer: one long-lived browser, reachable over HTTP. @@ -197,9 +196,9 @@ function botIdOf(request: Request, fallback?: string | null): string { * The Bot's durable files. * * Rooted at WORKSPACE_DIR, which the image creates and docker-compose mounts as a volume, so what a - * Bot saves outlives the container. Built once at boot: the root is fixed, and resolving it per request - * would only add a syscall to every call. Everything about why confinement is harder than it looks - * lives in workspace.ts. + * Bot saves outlives the container. Each folder is built the first time its Bot uses it and kept, so + * a request adds no syscall after that. Which folder a Bot gets lives in workspaces.ts; everything + * about why confinement is harder than it looks lives in workspace.ts. */ /** * The two directories this process cannot do its job without, checked before it says it is ready. @@ -245,8 +244,10 @@ await assertWritable( process.env.PROFILES_DIR?.trim() || "/profiles", ); -const workspace = createWorkspace( +// One folder per Bot where one process serves many; see workspaces.ts. +const workspaces = createWorkspaces( process.env.WORKSPACE_DIR?.trim() || "/workspace", + process.env.COMPUTER_SEPARATE_WORKSPACES?.trim().toLowerCase() === "on", ); /** @@ -286,9 +287,6 @@ const profiles = createProfiles( await sessions.get(botId)?.viewer.releaseAll(COMPUTER_STOPPED); }, ); -// Rooted in the same workspace the file tools use, so a command and a written file see one -// directory rather than two. -const shell = createShell(process.env.WORKSPACE_DIR?.trim() || "/workspace"); /** * The id normally arrives as a header on every request. This is the fallback for a caller that has no @@ -1209,9 +1207,8 @@ serve({ // MAY touch a path: the gateway in front of this process does that. if (url.pathname === "/files/download" && request.method === "GET") { try { - const file = await workspace.download( - url.searchParams.get("path") ?? "", - ); + const { files } = await workspaces.for(botId); + const file = await files.download(url.searchParams.get("path") ?? ""); return new Response(file.body, { headers: downloadHeaders(file.name, file.bytes), }); @@ -1228,7 +1225,8 @@ serve({ path?: unknown; } | null; try { - return json(await workspace.read(String(body?.path ?? ""))); + const { files } = await workspaces.for(botId); + return json(await files.read(String(body?.path ?? ""))); } catch (error) { return json( { error: describe(error, "The file could not be read.") }, @@ -1242,8 +1240,9 @@ serve({ path?: unknown; } | null; try { + const { files } = await workspaces.for(botId); return json( - await workspace.list( + await files.list( typeof body?.path === "string" ? body.path : undefined, ), ); @@ -1283,6 +1282,9 @@ serve({ return json({ error: timeout.error }, 400); } try { + // Rooted in the same folder the file tools use, so a command and a written file see one + // directory rather than two. + const { shell } = await workspaces.for(botId); return json( await shell.run({ command: body.command, @@ -1310,8 +1312,9 @@ serve({ return json({ error: "The contents to write are required." }, 400); } try { + const { files } = await workspaces.for(botId); return json( - await workspace.write(String(body?.path ?? ""), body.contents, { + await files.write(String(body?.path ?? ""), body.contents, { append: body.append === true, }), ); diff --git a/agent-computer/src/workspaces.ts b/agent-computer/src/workspaces.ts new file mode 100644 index 0000000000..5fb756346b --- /dev/null +++ b/agent-computer/src/workspaces.ts @@ -0,0 +1,60 @@ +/** + * Whose files a request reaches. + * + * WHY. One computer process can serve many Bots: Helm's default `computers.mode: shared` and the + * docker-compose `agent-computer` service with no supervisor both do. Browser profiles were already + * kept apart by the id each request names (`bot-id.ts`), but the workspace was one folder, so a note + * one Bot saved for one person was a file every other Bot could list and read. With + * `COMPUTER_SEPARATE_WORKSPACES=on` each id gets its own folder under the workspace root, and the + * shell starts there with HOME pointing at it. + * + * WHY NOT ALWAYS. A computer the supervisor or the sandbox starts serves one Bot, and the desktop's + * computer serves one person. Their files already sit at the root of the volume, and moving the + * root under them would hide every file they had. So only the deployments that share one process + * turn this on. + * + * WHAT IT IS NOT. A command is still a process in a shared container and can read any path the + * container can. Separate folders keep each Bot's file tools to its own files and keep commands from + * landing in another Bot's folder by default. Isolation between people who run commands needs a + * computer each, which `computers.mode: sandbox` gives. + */ +import { mkdir } from "node:fs/promises"; +import { isPlainBotId, UnusableBotIdError } from "./bot-id"; +import { createShell, type Shell } from "./shell"; +import { createWorkspace } from "./workspace"; + +export type Workspace = ReturnType; + +/** The folder an id's files live in. The same allow-list as profiles, so an id is one segment. */ +export function workspaceDirectoryFor( + root: string, + botId: string, + separate: boolean, +): string { + if (!separate) return root; + if (!isPlainBotId(botId)) throw new UnusableBotIdError(botId); + return `${root}/computers/${botId}`; +} + +export function createWorkspaces( + root: string, + separate: boolean, + sourceEnv: NodeJS.ProcessEnv = process.env, +) { + const made = new Map(); + return { + /** The files and shell for this id, creating its folder the first time it is used. */ + async for(botId: string): Promise<{ files: Workspace; shell: Shell }> { + const directory = workspaceDirectoryFor(root, botId, separate); + const known = made.get(directory); + if (known) return known; + await mkdir(directory, { recursive: true }); + const created = { + files: createWorkspace(directory), + shell: createShell(directory, sourceEnv), + }; + made.set(directory, created); + return created; + }, + }; +} diff --git a/agent-computer/tests/workspaces.test.ts b/agent-computer/tests/workspaces.test.ts new file mode 100644 index 0000000000..354f55982b --- /dev/null +++ b/agent-computer/tests/workspaces.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { UnusableBotIdError } from "../src/bot-id"; +import { createWorkspaces, workspaceDirectoryFor } from "../src/workspaces"; + +let root: string; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "workspaces-")); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +test("a computer serving many Bots gives each its own files", async () => { + const workspaces = createWorkspaces(root, true); + const sales = await workspaces.for("sales"); + const support = await workspaces.for("support"); + await sales.files.write("notes.md", "sales only"); + + expect((await sales.files.read("notes.md")).text).toBe("sales only"); + await expect(support.files.read("notes.md")).rejects.toThrow(); + expect((await support.files.list()).entries).toEqual([]); +}); + +test("a command starts in its Bot's folder, with HOME there too", async () => { + const workspaces = createWorkspaces(root, true, { PATH: process.env.PATH }); + const { shell } = await workspaces.for("sales"); + const result = await shell.run({ command: 'pwd; echo "$HOME"' }); + // pwd reports the real path, which differs where the temporary directory is a symlink (macOS). + const folder = await realpath(join(root, "computers", "sales")); + const [cwd, home] = result.stdout.trim().split("\n"); + expect(cwd).toBe(folder); + expect(await realpath(home ?? "")).toBe(folder); +}); + +test("a computer serving one Bot keeps its files where they were", async () => { + await writeFile(join(root, "notes.md"), "from before"); + const workspaces = createWorkspaces(root, false); + const { files } = await workspaces.for("sales"); + expect((await files.read("notes.md")).text).toBe("from before"); +}); + +test("an id that is not a plain name never becomes a folder", () => { + expect(() => workspaceDirectoryFor(root, "../etc", true)).toThrow( + UnusableBotIdError, + ); + expect(workspaceDirectoryFor(root, "../etc", false)).toBe(root); +}); diff --git a/charts/openbot/templates/computer/statefulset.yaml b/charts/openbot/templates/computer/statefulset.yaml index c5c96cb3cd..fde0a21d84 100644 --- a/charts/openbot/templates/computer/statefulset.yaml +++ b/charts/openbot/templates/computer/statefulset.yaml @@ -149,6 +149,12 @@ spec: secretKeyRef: name: {{ default (include "openbot.secretName" .) .Values.computers.existingTokenSecret }} key: computer-token + {{- /* + Every Bot reaches this one pod, so each gets its own folder of files and its shell + starts there. See agent-computer/src/workspaces.ts. + */}} + - name: COMPUTER_SEPARATE_WORKSPACES + value: "on" {{- with .Values.computers.maxBrowsers }} - name: COMPUTER_MAX_BROWSERS value: {{ . | quote }} diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml index 5a1dfcb733..042ba6d865 100644 --- a/charts/openbot/values.yaml +++ b/charts/openbot/values.yaml @@ -327,7 +327,8 @@ config: # How a Bot gets a computer. # # shared One browser for every Bot, run by this chart. Needs nothing installed in the cluster, -# and is what a first install should use. +# and is what a first install should use. Each Bot keeps its own logins and files, +# but a command one Bot runs can read another's files. # sandbox A computer each, as a `Sandbox` from kubernetes-sigs/agent-sandbox, suspended when # idle and resumed with its logins intact. Needs that controller in the cluster. # external Neither. `url` points at a computer somebody else runs. diff --git a/docker-compose.yml b/docker-compose.yml index a7c8614439..8677f7708a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,6 +116,9 @@ services: # minutes; closing a browser costs only a relaunch, because the profile is on the volume below. COMPUTER_MAX_BROWSERS: ${COMPUTER_MAX_BROWSERS:-} COMPUTER_BROWSER_IDLE_MS: ${COMPUTER_BROWSER_IDLE_MS:-} + # Every Bot without a computer of its own reaches this one, so each gets its own folder of + # files and its shell starts there. The supervisor's per-Bot computers do not need it. + COMPUTER_SEPARATE_WORKSPACES: "on" volumes: - agent-workspace:/workspace # Chromium's user-data directory is volume-backed so logins survive container restarts. diff --git a/docker/s6/s6-rc.d/computer/run b/docker/s6/s6-rc.d/computer/run index 0e71e8cb9b..972d55e788 100755 --- a/docker/s6/s6-rc.d/computer/run +++ b/docker/s6/s6-rc.d/computer/run @@ -26,6 +26,7 @@ for name in \ BUN_INSTALL PLAYWRIGHT_BROWSERS_PATH SPIFFE_ENDPOINT_SOCKET \ COMPUTER_TOKEN COMPUTER_BOT_ID COMPUTER_BROWSER_MODE COMPUTER_MAX_BROWSERS \ COMPUTER_BROWSER_IDLE_MS COMPUTER_SANDBOX COMPUTER_SHELL_ENV COMPUTER_STOPPED \ + COMPUTER_SEPARATE_WORKSPACES \ ACTION_TIMEOUT_MS NAVIGATION_TIMEOUT_MS AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS do eval "isset=\${$name+x}" diff --git a/docs/configuration.md b/docs/configuration.md index 2f0af4edf3..03dc400abc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -628,6 +628,7 @@ is still the record. | `AGENT_COMPUTER_URL` | Shared computer URL. If absent, computer routes are not mounted. | | `COMPUTER_TOKEN` | Secret every computer request must present. The computer refuses to start without it. | | `COMPUTER_MAX_BROWSERS` | How many Bots may hold a running browser at once. `8` by default; the least recently used is closed past it. | +| `COMPUTER_SEPARATE_WORKSPACES` | `on` gives each Bot its own folder of files on a computer many Bots share, with its shell starting there. On for Helm's `shared` computer and the docker-compose `agent-computer`; off elsewhere. Files saved before it was on stay at the root of the volume. Commands can still read other folders: use `computers.mode: sandbox` to keep people apart. | | `COMPUTER_BROWSER_IDLE_MS` | How long an untouched browser is kept. 30 minutes by default; `0` keeps them resident. | | `COMPUTER_BROWSER_BACKEND` | `managed` by default (full bundled Chromium); `local-chrome` opts into installed Chrome with dedicated profiles and a loopback API. | | `COMPUTER_BROWSER_MODE` | Managed defaults to `headed` on Linux, which is every hosted computer (a container, a pod, the desktop app's engine): full Chromium on the computer's own Xvfb display. A computer run directly on macOS or Windows defaults to `headless`, where headed would open windows on the desktop. `headless` is full Chromium's new headless mode. Local Chrome requires `headed`. See [Getting past bot checks](#getting-past-bot-checks). | From 8fd3ff4ad7e3507dc1251ec7c1947eb612287885 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 15:25:38 -0300 Subject: [PATCH 70/73] End a hop at once when the other Bot's address is one this deployment will not dial --- server/src/agents/handoff-runner.ts | 10 ++++++--- server/tests/agent-handoff-runner.test.ts | 26 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/server/src/agents/handoff-runner.ts b/server/src/agents/handoff-runner.ts index 44add93283..9da2c30eee 100644 --- a/server/src/agents/handoff-runner.ts +++ b/server/src/agents/handoff-runner.ts @@ -473,10 +473,14 @@ export function createHandoffRunner(options: { * this hop. A person who was told their question had been handed on, and then hears * nothing for ever, has no way to tell a slow Bot from a broken one. */ - // The deployment's controls refused it (Use Bots, the model allowlist). Trying again in a - // minute gets the same answer, so this is the last try, whatever the count says. + // The deployment's controls refused it (Use Bots, the model allowlist), or the other + // Bot's address is one this deployment will not dial. Trying again in a minute gets the + // same answer, so this is the last try, whatever the count says. Retrying a refused + // address left the person watching "Asking …" for five minutes before being told. const refused = - error instanceof Error && error.name === "CapabilityRefusedError"; + error instanceof Error && + (error.name === "CapabilityRefusedError" || + error.name === "EndpointNotAllowedError"); if ((refused || item.attempts >= maxAttempts) && !work.answerIn) { await tell(work, item.key, reason).catch((failure) => { // A notice that cannot be queued must not take the release with it: leaving the row diff --git a/server/tests/agent-handoff-runner.test.ts b/server/tests/agent-handoff-runner.test.ts index b3bda1f248..d42fdd559e 100644 --- a/server/tests/agent-handoff-runner.test.ts +++ b/server/tests/agent-handoff-runner.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { readRunAssertion } from "../src/agents/callback-token"; +import { EndpointNotAllowedError } from "../src/agents/endpoint"; import { ThreadBusyError } from "../src/agents/handoff-delivery"; import { createHandoffRunner, @@ -411,6 +412,31 @@ describe("a hop that failed for good", () => { expect(offered[0]?.task).toContain("Use Bots is turned off"); }); + test("a hop to an address this deployment will not dial ends on its first try", async () => { + const { + runner: sweeper, + offered, + calls, + } = runner({ + deliver: async () => { + throw new EndpointNotAllowedError( + "This deployment will not dial http://localhost:4201/ag-ui: that address is inside OpenBot's own network, so an agent may not live there.", + ); + }, + }); + + await sweeper.sweep(); + + expect(calls.filter((call) => call.verb === "release")).toEqual([]); + expect(calls).toContainEqual({ + verb: "finish", + key: "run-1:abc", + owner: "replica-a", + }); + expect(offered).toHaveLength(1); + expect(offered[0]?.task).toContain("will not dial"); + }); + /* * The fan-out cap counts every row whose key starts with the run's own prefix. A notice is not one * of the Bots this run asked for, and a run long enough to see a hop fail for good is exactly the From a0070bb017f5865333f483b0298b40ae3f51e91f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 15:36:21 -0300 Subject: [PATCH 71/73] Recognise a refused address by its message, since the AG-UI client keeps only that --- server/src/agents/endpoint.ts | 20 +++++++++++++++++++- server/src/agents/handoff-runner.ts | 7 ++++--- server/tests/agent-handoff-runner.test.ts | 5 +++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/server/src/agents/endpoint.ts b/server/src/agents/endpoint.ts index 52074f657e..133c00759e 100644 --- a/server/src/agents/endpoint.ts +++ b/server/src/agents/endpoint.ts @@ -129,6 +129,24 @@ export class EndpointNotAllowedError extends Error { } } +/** How a refusal of the stored address begins, whoever ends up holding its message. */ +const NOT_DIALLED = "This deployment will not dial "; + +/** + * Whether a run failed because its stored address is one this deployment will not dial. + * + * By message as well as by type, because the AG-UI client turns a fetch that throws into a RUN_ERROR + * event carrying only the message, so the error a caller finally holds is a plain `Error`. Only the + * stored address counts: a redirect can stop redirecting, and that address cannot change on its own. + */ +export function isEndpointRefusal(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === "EndpointNotAllowedError" || + error.message.startsWith(NOT_DIALLED)) + ); +} + const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); /** @@ -282,7 +300,7 @@ export function createAgentFetch( if (!stored.allowed) { throw refuse( url, - `This deployment will not dial ${url}: ${stored.reason.charAt(0).toLowerCase()}${stored.reason.slice(1)}`, + `${NOT_DIALLED}${url}: ${stored.reason.charAt(0).toLowerCase()}${stored.reason.slice(1)}`, ); } diff --git a/server/src/agents/handoff-runner.ts b/server/src/agents/handoff-runner.ts index 9da2c30eee..db052a2ac1 100644 --- a/server/src/agents/handoff-runner.ts +++ b/server/src/agents/handoff-runner.ts @@ -17,6 +17,7 @@ import { } from "../audit"; import { cutAtCodeUnits } from "../channels/text"; import { DEFAULT_MAX_ATTEMPTS, type WorkQueue } from "../work/queue"; +import { isEndpointRefusal } from "./endpoint"; import { HANDOFF_KIND } from "./handoff"; import { ThreadBusyError } from "./handoff-delivery"; import { BOT_PAUSED_REASON, isBotPaused } from "./lifecycle"; @@ -478,9 +479,9 @@ export function createHandoffRunner(options: { // same answer, so this is the last try, whatever the count says. Retrying a refused // address left the person watching "Asking …" for five minutes before being told. const refused = - error instanceof Error && - (error.name === "CapabilityRefusedError" || - error.name === "EndpointNotAllowedError"); + (error instanceof Error && + error.name === "CapabilityRefusedError") || + isEndpointRefusal(error); if ((refused || item.attempts >= maxAttempts) && !work.answerIn) { await tell(work, item.key, reason).catch((failure) => { // A notice that cannot be queued must not take the release with it: leaving the row diff --git a/server/tests/agent-handoff-runner.test.ts b/server/tests/agent-handoff-runner.test.ts index d42fdd559e..01972b5dba 100644 --- a/server/tests/agent-handoff-runner.test.ts +++ b/server/tests/agent-handoff-runner.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; import { readRunAssertion } from "../src/agents/callback-token"; -import { EndpointNotAllowedError } from "../src/agents/endpoint"; import { ThreadBusyError } from "../src/agents/handoff-delivery"; import { createHandoffRunner, @@ -418,8 +417,10 @@ describe("a hop that failed for good", () => { offered, calls, } = runner({ + // As it arrives: the AG-UI client rebuilds the refusal from its RUN_ERROR, so only the + // message survives. deliver: async () => { - throw new EndpointNotAllowedError( + throw new Error( "This deployment will not dial http://localhost:4201/ag-ui: that address is inside OpenBot's own network, so an agent may not live there.", ); }, From 8ad807e2d774d62c115dc359a8b3cca40c39fcda Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 16:19:10 -0300 Subject: [PATCH 72/73] Show the asked Bot's avatar on the waiting row, and drop the clock --- CHANGELOG.md | 7 +- app/src/components/channels/channel-chat.tsx | 4 +- app/src/components/channels/channel-work.tsx | 36 +++++--- app/src/components/channels/progress-row.tsx | 45 ++-------- app/tests/channel-work-row.test.tsx | 90 +++++++------------- 5 files changed, 70 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67cd8e16a0..1d452d0975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,9 +42,10 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ### A conversation shows who it is waiting on -When a Bot asks another Bot, the end of the conversation now reads "Asking Risk Analyst… 0:13", with -the clock running, until the answer comes back; a routine firing into it reads "Market Scout is -running a routine…". The sidebar keeps the conversation lit while it waits, and both survive a +When a Bot asks another Bot, the end of the conversation now shows that Bot's avatar and "Asking +Risk Analyst…" until the answer comes back; a routine firing into it reads "Market Scout is running a +routine…". A Bot whose address this deployment will not dial is reported at once, not after five +minutes of retries. The sidebar keeps the conversation lit while it waits, and both survive a reload. In a shared conversation only the person whose Bot asked sees whom it asked; the others see only that it is busy. Migration `0063_work_items_open_thread` adds one partial index to `work_items`. diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 74d67cd8d7..14a91aa4dc 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -1092,8 +1092,8 @@ export function ChannelChat({ working={ agent.isRunning || turnsInFlight > 0 ? null : ( - agentProfiles?.find((profile) => profile.id === botId)?.name + botOf={(botId) => + agentProfiles?.find((profile) => profile.id === botId) } work={channel.work} /> diff --git a/app/src/components/channels/channel-work.tsx b/app/src/components/channels/channel-work.tsx index 43c32464fd..c78531cf81 100644 --- a/app/src/components/channels/channel-work.tsx +++ b/app/src/components/channels/channel-work.tsx @@ -1,6 +1,10 @@ +import { AbstractAvatar } from "@/components/agents/abstract-avatar"; import type { ChannelWork } from "@/lib/channels/queries"; import { ProgressRow } from "./progress-row"; +/** A Bot as this person knows it: its name and the seed its avatar is drawn from. */ +export type KnownBot = { name: string; avatarSeed: string }; + /** * What a conversation is waiting on, one row each, at the end of the transcript. * @@ -14,22 +18,34 @@ import { ProgressRow } from "./progress-row"; */ export function ChannelWorkRows({ work, - nameOf, + botOf, }: { work: readonly ChannelWork[] | undefined; - /** The name this person knows a Bot by, when they know it at all. */ - nameOf?: (botId: string) => string | undefined; + /** The Bot this person knows by that id, when they know it at all. */ + botOf?: (botId: string) => KnownBot | undefined; }) { if (!work || work.length === 0) return null; return (

); } diff --git a/app/src/components/channels/progress-row.tsx b/app/src/components/channels/progress-row.tsx index 3160ebfac5..e63c4a2323 100644 --- a/app/src/components/channels/progress-row.tsx +++ b/app/src/components/channels/progress-row.tsx @@ -1,60 +1,33 @@ -import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; /** - * One line saying something is under way, and for how long. + * One line saying something is under way, with who is doing it. * * For a wait the person did not start and cannot watch: a Bot asked by their Bot, a routine firing, * a sign-in or an approval somebody else has to finish. Drawn where the result will appear, so the - * person looks at the right spot, and with a running clock, because "still working" and "stuck" - * look the same without one. + * person looks at the right spot. * * It borrows the shimmer a running tool line uses, so every kind of "working on it" in the * transcript reads the same. */ export function ProgressRow({ label, - since, + media, }: { /** What is under way, as a person would say it: "Asking Ana…". */ label: string; - /** When it started. ISO-8601, or epoch milliseconds. */ - since: string | number; + /** Who is doing it, drawn before the words: a Bot's avatar. */ + media?: ReactNode; }) { - const elapsed = useElapsed(since); return (

+ {media ? : null} {label} -

); } - -/** "0:12", "4:05", "1:02:07". Never negative: a server clock a little ahead reads as zero. */ -export function formatElapsed(milliseconds: number): string { - const total = Math.max(0, Math.floor(milliseconds / 1000)); - const hours = Math.floor(total / 3600); - const minutes = Math.floor((total % 3600) / 60); - const seconds = String(total % 60).padStart(2, "0"); - return hours > 0 - ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` - : `${minutes}:${seconds}`; -} - -/** The time since `since`, as text, moving once a second while it is on screen. */ -function useElapsed(since: string | number): string { - const start = typeof since === "number" ? since : Date.parse(since); - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, []); - return formatElapsed(Number.isNaN(start) ? 0 : now - start); -} diff --git a/app/tests/channel-work-row.test.tsx b/app/tests/channel-work-row.test.tsx index 9ef09032e2..a37164c9f9 100644 --- a/app/tests/channel-work-row.test.tsx +++ b/app/tests/channel-work-row.test.tsx @@ -1,12 +1,4 @@ -import { - afterAll, - afterEach, - beforeAll, - expect, - jest, - setSystemTime, - test, -} from "bun:test"; +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { QueryClient, @@ -15,7 +7,6 @@ import { } from "@tanstack/react-query"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { ChannelWorkRows } from "@/components/channels/channel-work"; -import { formatElapsed } from "@/components/channels/progress-row"; import { type AgentChannel, type ChannelWork, @@ -26,17 +17,15 @@ import { applyChannelWork } from "@/lib/channels/use-channel-events"; import { settleReactWork } from "./settle-react-work"; /** - * The row a conversation shows while it waits on work nobody's browser is running: "Asking Ana… - * 0:12" for a Bot its Bot asked, and the same for a routine. Driven by the server's list, so it - * has to appear from a fetched channel as well as from a socket event, tick while it is up, and go - * when the list empties. + * The row a conversation shows while it waits on work nobody's browser is running: the Bot's avatar + * and "Asking Ana…" for a Bot its Bot asked, and the same for a routine. Driven by the server's + * list, so it has to appear from a fetched channel as well as from a socket event, and go when the + * list empties. */ beforeAll(() => GlobalRegistrator.register()); afterEach(() => { cleanup(); - jest.useRealTimers(); - setSystemTime(); }); afterAll(async () => { await settleReactWork(); @@ -48,8 +37,6 @@ afterEach(() => { global.fetch = originalFetch; }); -const NOW = new Date("2026-10-26T10:00:12.000Z"); - const asking = (overrides: Partial = {}): ChannelWork => ({ kind: "handoff", @@ -60,49 +47,41 @@ const asking = (overrides: Partial = {}): ChannelWork => ...overrides, }) as ChannelWork; -test("says who was asked and for how long", () => { - setSystemTime(NOW); - +test("says who was asked, with their avatar and no clock", () => { const view = render(); const row = view.getByTestId("progress-row"); - expect(row.textContent).toBe("Asking Ana…0:12"); - // Announced as progress, and only by its words: the clock is hidden from a screen reader. + expect(row.textContent).toBe("Asking Ana…"); expect(row.getAttribute("role")).toBe("status"); - expect(row.querySelector("[aria-hidden='true']")?.textContent).toBe("0:12"); + // The avatar is decoration beside a label that already names the Bot, so it is announced once. + const avatar = row.querySelector("[aria-hidden='true'] [role='img']"); + expect(avatar?.getAttribute("aria-label")).toBe("Ana"); + expect(row.querySelector("svg")).not.toBeNull(); }); -test("the clock moves while the row is up", () => { - jest.useFakeTimers(); - setSystemTime(NOW); - const view = render(); - - // The fake clock moves `Date.now()` with the timers, as a real second would. - act(() => { - jest.advanceTimersByTime(3_000); - }); - - expect(view.getByTestId("progress-row").textContent).toBe("Asking Ana…0:15"); -}); - -test("prefers the name the person knows the Bot by", () => { - setSystemTime(NOW); - +test("prefers the name and avatar the person knows the Bot by", () => { const view = render( (botId === "ana" ? "Ana Research" : undefined)} + botOf={(botId) => + botId === "ana" + ? { name: "Ana Research", avatarSeed: "seed-ana" } + : undefined + } work={[asking()]} />, ); + const known = view.getByTestId("progress-row").innerHTML; + cleanup(); + const unknown = render().getByTestId( + "progress-row", + ).innerHTML; - expect(view.getByTestId("progress-row").textContent).toContain( - "Asking Ana Research…", - ); + expect(known).toContain("Asking Ana Research…"); + // Drawn from the Bot's own seed, so it is the same face the Bot has everywhere else. + expect(known.replace("Ana Research", "Ana")).not.toBe(unknown); }); test("one row for each Bot asked, and one for a routine", () => { - setSystemTime(NOW); - const view = render( { expect( view.getAllByTestId("progress-row").map((row) => row.textContent), ).toEqual([ - "Asking Ana…0:12", - "Asking another Bot…0:12", - "Assistant is running a routine…1:12", + "Asking Ana…", + "Asking another Bot…", + "Assistant is running a routine…", ]); }); test("goes when the list empties", () => { - setSystemTime(NOW); const view = render(); view.rerender(); @@ -144,7 +122,6 @@ test("goes when the list empties", () => { * conversation a Bot is still answering in looks finished to anybody who refreshed. */ test("comes back from the channel read after a reload", async () => { - setSystemTime(NOW); global.fetch = (async (input: RequestInfo | URL) => { if (String(input) === "/api/channels/channel-1") { return Response.json({ @@ -176,9 +153,7 @@ test("comes back from the channel read after a reload", async () => { ); await waitFor(() => { - expect(view.getByTestId("progress-row").textContent).toBe( - "Asking Ana…0:12", - ); + expect(view.getByTestId("progress-row").textContent).toBe("Asking Ana…"); }); // The socket then says the answer is being given, and the row goes. @@ -231,10 +206,3 @@ test("an event with no list leaves the conversation's list alone", () => { }), ).toBe(channel); }); - -test("the clock reads minutes, then hours, and never runs backwards", () => { - expect(formatElapsed(12_000)).toBe("0:12"); - expect(formatElapsed(245_000)).toBe("4:05"); - expect(formatElapsed(3_727_000)).toBe("1:02:07"); - expect(formatElapsed(-4_000)).toBe("0:00"); -}); From 7365713fe99cc70ea0318fd9498559feeeb95bc9 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 26 Oct 2026 16:29:24 -0300 Subject: [PATCH 73/73] Read container health in its own file, so its test does not need dockerode --- supervisor/src/docker.ts | 13 ++----------- supervisor/src/health.ts | 20 ++++++++++++++++++++ supervisor/tests/health.test.ts | 2 +- 3 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 supervisor/src/health.ts diff --git a/supervisor/src/docker.ts b/supervisor/src/docker.ts index d378f2634e..5418092691 100644 --- a/supervisor/src/docker.ts +++ b/supervisor/src/docker.ts @@ -1,4 +1,5 @@ import Docker from "dockerode"; +import { type ContainerHealth, healthOf } from "./health"; import { BOT_LABEL, type ComputerNames, @@ -99,19 +100,9 @@ export type ComputerState = { * `running` only means the process exists. A container still `starting` here is booting its * browser, and `ensure` would hold the caller until it is healthy, so a status read says so. */ - health?: "starting" | "healthy" | "unhealthy"; + health?: ContainerHealth; }; -/** The healthcheck verdict Docker writes into a container's status line, e.g. "Up 3 seconds (health: starting)". */ -export function healthOf( - status: string | undefined, -): ComputerState["health"] | undefined { - const match = /\((?:health: )?(starting|healthy|unhealthy)\)/.exec( - status ?? "", - ); - return match?.[1] as ComputerState["health"] | undefined; -} - /** * The name this Bot's computer would have is held by a container this supervisor does not own. * diff --git a/supervisor/src/health.ts b/supervisor/src/health.ts new file mode 100644 index 0000000000..d55a825b47 --- /dev/null +++ b/supervisor/src/health.ts @@ -0,0 +1,20 @@ +/** + * Reading a container's healthcheck from the status line Docker writes. + * + * In its own file rather than in `docker.ts` for the reason `names.ts` is: that file imports + * `dockerode` at module scope, so a test of a one-line parse would need the Docker client installed + * merely to be imported. + */ + +/** What the image's healthcheck says, when Docker reports one. */ +export type ContainerHealth = "starting" | "healthy" | "unhealthy"; + +/** The healthcheck verdict Docker writes into a container's status line, e.g. "Up 3 seconds (health: starting)". */ +export function healthOf( + status: string | undefined, +): ContainerHealth | undefined { + const match = /\((?:health: )?(starting|healthy|unhealthy)\)/.exec( + status ?? "", + ); + return match?.[1] as ContainerHealth | undefined; +} diff --git a/supervisor/tests/health.test.ts b/supervisor/tests/health.test.ts index 488a9e3ea1..6b54ad9b1d 100644 --- a/supervisor/tests/health.test.ts +++ b/supervisor/tests/health.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { healthOf } from "../src/docker"; +import { healthOf } from "../src/health"; describe("reading a container's healthcheck from its status line", () => { test.each([