diff --git a/CHANGELOG.md b/CHANGELOG.md index 5092397c24..d2290be58c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,18 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -**Before upgrading.** No migrations run in this release: the database stays at -`0088_approval_reviewer_guidance`, and a 1.0.1 database is used as it is. Upgrading from 1.0.0 or +**The sidebar in sections.** The conversation sidebar now draws its conversations in three +collapsible sections — Group chats, Direct chats and Projects — and a person can make folders and +file conversations into them, by dragging a conversation onto a folder, from the conversation's +right-click menu, or from the "Move to project" button beside an open conversation. A folder is +one person's filing, so two people can file the same +conversation differently, and filing one takes it out of Group/Direct for that person only. Which +sections are folded is kept on the account, so it follows a person across devices. Upgrading runs +`0089`, which only adds: `projects` and `channel_projects` with a unique index, and nothing +existing is altered. + +**Before upgrading.** Apart from `0089` above, no migrations run in this release: the database +stays at `0088_approval_reviewer_guidance`, and a 1.0.1 database is used as it is. Upgrading from 1.0.0 or 0.3.x runs the migrations listed under 1.0.1, and a database that has run them does not go back: restore the backup taken before the upgrade instead. Three things change for an existing OpenBot Desktop installation: diff --git a/TASK-sidebar-sections.md b/TASK-sidebar-sections.md new file mode 100644 index 0000000000..71677128b3 --- /dev/null +++ b/TASK-sidebar-sections.md @@ -0,0 +1,113 @@ +# Task: Group the sidebar conversations into collapsible sections (Group chats / Direct chats / Projects) + +> Handoff note (devops agent, 2026-11-12). Repo: `/root/repos/openbot-src` (bun 1.3.14, workspace +> `app` + `server`). Kaneo: DevOps project, task #8 (stages #9–#12). +> **The work is already ~70% done and uncommitted** — see "Current state" at the bottom. +> Do not restart from scratch: finish, verify, commit. + +You are working in the OpenBot monorepo (CopilotKit/OpenBot). First read the existing sidebar / +channel-list components in `app/src/` and the channels routes in `server/src/`, and follow the +conventions you find there (naming, data fetching, state management, design tokens). + +## Goal +The sidebar shows conversations in three collapsible sections, in this order: +1. Group chats — channels with more than one human member or more than one Bot. +2. Direct chats — 1:1 channels between the signed-in person and a single coworker. +3. Projects — user-defined folders a person can create, rename, delete, and move channels into. + +## Requirements + +### Backend (server) +- PostgreSQL migration: `projects` (id, owner user id, name, sort order, created_at) and a + nullable `project_id` on channels (or a join table — justify the choice in the PR description). + A channel belongs to at most one project. +- Routes per existing API conventions: + - `GET /api/projects` — the caller's projects with their channels' ids; + - `POST /api/projects`, `PATCH /api/projects/:id`, `DELETE /api/projects/:id` (deleting a + project must NOT delete its channels — they return to the ungrouped list); + - `PATCH /api/channels/:id/project` — move a channel into a project or out (`null`). +- Authorization: only the project owner can rename/delete it and move channels into it; moving + requires channel membership (the same check the channel routes use). Never leak channel titles + to non-members through project endpoints. +- Persist per-user collapsed/expanded section state server-side (extend `agent_preferences` or + an equivalent existing mechanism; no browser localStorage). + +### Frontend (app) +- Section header: chevron, title, conversation count; click toggles collapse; state survives reload. +- Channels sorted by most recent activity (keep current ordering logic). A channel inside a + Project does not also appear under Group/Direct. +- Group/Direct classification computed from data the app already fetches — no N+1 requests. +- Channel context menu: "Move to project ▸" (user's projects + "New project…"), "Remove from + project" when applicable; shown only per the authorization rules. +- Projects section header has "+" to create a project (inline rename input). +- Per-section empty states; sidebar search (if present) filters across sections and + force-expands sections with matches. +- Use existing design tokens/components; correct in light and dark themes; no new dependencies. + +### Constraints +- Do not change the channel permission model: every existing channel route keeps resolving the + caller in `channel_memberships`. This feature is organization-only. +- Do not break the desktop (Tauri) build; sidebar must work at narrow widths. +- Follow docs/development.md: run migrations locally; lint/typecheck/test targets must pass. +- Tests: server route tests for project CRUD authorization and non-member title isolation; + component tests for section toggle and move-to-project menu if the repo has a component-test setup. +- Keep the diff reviewable: one migration, server routes + tests, frontend + tests, docs note. + +## Definition of done +- Three sections render; group/direct classification matches membership. +- Create project "Website redesign", move two channels in, collapse/expand, reload — state persists. +- Deleting the project returns channels to normal sections, intact. +- Existing tests pass; new tests cover the rules above. + +## Defaults (record any deviations in the PR description) +- A group channel CAN be in a project (any channel the user is a member of). +- Projects are per-user: the owner sees channels grouped, other members see them as usual. + +--- + +## Current state in this checkout (measured 2026-11-12 05:2x UTC) + +Uncommitted on `main`, 12 modified + 15 new files. Earlier session ran out of tool iterations +mid-frontend; verify everything, do not assume it is correct. + +Server half (believed complete, unreviewed): +- `server/src/db/schema/projects.ts` — `projects` + `channel_projects` (join table with + `owner_user_id`; decision recorded in the schema comments: a folder is per person, so a + `project_id` column on `channels` would force one folder for everybody who sees the channel) +- `server/drizzle/0089_exotic_ultragirl.sql` + `meta/0089_snapshot.json` +- `server/src/projects/store.ts` (261 lines), `server/src/projects/routes.ts` (171 lines) +- wiring: `server/src/app.ts`, `server/src/index.ts`, `server/src/channels/routes.ts` + (`memberCount`, `projectId` in the roster payload), `server/src/db/schema/index.ts` +- section collapse state: `shared/user-preferences.ts`, `server/src/user-preferences.ts`, + `server/src/user-preferences-routes.ts` + +App half (in progress): +- `app/src/components/app-sidebar/conversation-sections.tsx` (461 lines), `section-header.tsx`, + `move-to-project-menu.tsx`, `new-project-dialog.tsx`, `delete-project-dialog.tsx`, + `project-name-input.tsx` +- `app/src/lib/channels/sections.ts` (209), `order.ts`, `unread.ts`, + `app/src/lib/projects/{queries,mutations}.ts`, `app/src/lib/settings/sidebar-sections.ts` +- `app/src/components/app-sidebar/app-sidebar.tsx`, `channel.tsx`, `app/src/lib/channels/queries.ts` + +Gate status at handoff: +- `bun run typecheck` → **app fails**: 10 TS errors, all "Property 'sidebarSections' is missing in + type 'UserPreferences'" in `tests/app-navigation.test.tsx`, `channel-pagination.test.tsx`, + `narrow-sidebar-sheet.test.tsx`, `self-host-banner.test.tsx`, `skins-account.test.tsx`, + `user-preferences.test.tsx`. Fix by one uniform mechanism (a fixture builder or a default in the + type), not file-by-file patching. +- `server` typecheck was killed with SIGKILL (looked like OOM) and is therefore **unverified**; + re-run it alone. +- No tests exist for projects yet. Docs untouched. + +Environment notes: +- `bun` 1.3.14 at `/root/.bun/bin/bun`; deps installed (1.6G `node_modules`). +- Test Postgres: container `openbot-test-pg` on `127.0.0.1:55432`, db `openbot_test` + (user/password `openbot`). Migration 0089 was applied there by the earlier session — re-run + `bun run db:migrate` if in doubt. +- Running deployment: container `openbot` (server on `127.0.0.1:3001`). Do not restart or redeploy + it from here without asking; verify the UI in a dev server instead (`bun run dev`). +- `gh` is **not installed**, the remote is upstream `https://github.com/CopilotKit/OpenBot` over + HTTPS, there is no fork and no token: do **not** attempt to push or open a PR. Deliver a local + branch `feat/sidebar-sections`, a `sidebar-sections.patch`, and a filled-in PR body draft. +- Repo git identity is empty: set it repo-locally before the first commit + (`git config user.name "Sergey Shabalin"; git config user.email "mail@sshabalin.ru"`). \ No newline at end of file diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index ac8892ad42..1e60fa6e2e 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -1,7 +1,6 @@ import { IconLogout, IconPlus, IconSearch } from "@tabler/icons-react"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { Link, type LinkOptions, useParams } from "@tanstack/react-router"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { Link, type LinkOptions } from "@tanstack/react-router"; import type * as React from "react"; import { useRef, useState } from "react"; import { BrandName } from "@/components/edition/brand-name"; @@ -30,30 +29,32 @@ import { } from "@/components/ui/sidebar"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { useSignOutOffered } from "@/lib/auth/sign-out-offered"; -import { untitledLine } from "@/lib/channels/label"; -import { - type ChannelSummary, - channelListQueryOptions, -} from "@/lib/channels/queries"; +import { matchingChannels } from "@/lib/channels/order"; +import { channelListQueryOptions } from "@/lib/channels/queries"; import { useChannelEvents } from "@/lib/channels/use-channel-events"; import { appConfig } from "@/lib/generated/application-config"; -import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion"; import { isNavigationLink, useNavigationItems } from "@/lib/navigation"; -import { relativeTimeShort } from "@/lib/relative-time"; -import { - type MessageListEmphasis, - useMessageListEmphasis, -} from "@/lib/settings/message-list"; +import { projectsQueryOptions } from "@/lib/projects/queries"; +import { useMessageListEmphasis } from "@/lib/settings/message-list"; +import { useSidebarSections } from "@/lib/settings/sidebar-sections"; import { BotsNavBadge } from "../bot-profile/attention"; import { BrowserNavItem } from "../browser/browser-nav-item"; import { navigationRender } from "../edition/team-features-dialog"; import { OpenBotVersion } from "../layout/openbot-version"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; -import { Channel } from "./channel"; import { ChannelPagination } from "./channel-pagination"; +import { ConversationSections } from "./conversation-sections"; import { useSignOut } from "./use-sign-out"; +/* + * The roster's two reading rules live in `lib/channels/order.ts` now: the sidebar applies them per + * section, and the skins' sheets still import them from here. Re-exported rather than moved out of + * sight, because these are what other surfaces already reach for. + */ +export { hasUnseenActivity, isUnread } from "@/lib/channels/unread"; +export { matchingChannels, pinnedFirst } from "@/lib/channels/order"; + const appLinkOptions = { to: "/" } satisfies LinkOptions; const userMenuItemClassName = "gap-2 px-2 py-1.5"; @@ -80,144 +81,22 @@ function UserAvatar() { */ const MAX_ANIMATED_ROWS = 60; -/** - * The roster, narrowed to what the person typed. - * - * Matches the channel's name, its summary, its opening and the last message, because those are the - * things the row can actually show — searching against something invisible returns results a person - * cannot account for. The opening and the last message are included because one of them is what the - * second line draws until the conversation has been named. Message history beyond that line is not - * here to search: it lives in the thread store, and reaching for it is a server endpoint rather than - * a filter. - * - * An empty query returns the input array unchanged rather than a copy, so typing and clearing does - * not hand `AnimatePresence` a new array identity and restage the whole list. - */ -export function matchingChannels( - channels: ChannelSummary[] | undefined, - query: string, -): ChannelSummary[] { - if (!channels) { - return []; - } - const needle = query.trim().toLowerCase(); - if (!needle) { - return channels; - } - return channels.filter((channel) => - [channel.name, channel.summary, channel.opening, channel.lastMessage].some( - (field) => field?.toLowerCase().includes(needle), - ), - ); -} - -/** - * Pinned channels first, everything else after, newest activity first within each group. - * - * The mirror of a server rule, not the rule itself: the roster query orders pinned-first and its - * cursor carries the pin, so a pinned channel arrives on page one however long ago it was last - * spoken in. Sorting here as well is for what happens between refetches — the socket patches a pin - * onto a loaded row without moving it, and re-sorts a page by recency alone — which is the same - * reason `byRecency` in use-channel-events.ts mirrors the recency rule. A stable partition, so the - * recency order inside each group is whatever arrived. - */ -export function pinnedFirst(channels: ChannelSummary[]): ChannelSummary[] { - return [...channels].sort((a, b) => Number(b.pinned) - Number(a.pinned)); -} - -/** - * Whether a Bot has said something this member has not had on screen yet. - * - * A Bot's message, and only a Bot's: your own message carries a null agent id and reading your own - * words needs no marker. ISO-8601 strings compare correctly as strings, which is the same bet the - * server's recency sort already makes. - */ -export function hasUnseenActivity(channel: ChannelSummary): boolean { - if (channel.lastMessageAgentId === null || channel.lastMessageAt === null) { - return false; - } - return ( - channel.lastReadAt === null || channel.lastMessageAt > channel.lastReadAt - ); -} - -/** Unseen activity somewhere you are not looking. The open channel never shows the dot. */ -export function isUnread( - channel: ChannelSummary, - openChannelId: string | undefined, -): boolean { - return channel.id !== openChannelId && hasUnseenActivity(channel); -} - -/** - * A roster row that can animate. - * - * Two movements only: a channel that did not exist fades in, and a channel that was just spoken in - * moves to the top. Nothing else animates, a roster that reacts to being read is a roster that - * moves under the cursor. - */ -function ChannelRow({ - channel, - animateOrder, - emphasis, -}: { - channel: ChannelSummary; - animateOrder: boolean; - emphasis: MessageListEmphasis; -}) { - const shouldReduceMotion = useReducedMotion(); - // Whether this row is unread, as a boolean, for the same reason `Channel` computes `isOpen` - // that way: navigating re-renders the rows whose answer changed, not the whole roster. - const unread = useParams({ - strict: false, - select: (params) => - isUnread(channel, (params as { channelId?: string }).channelId), - }); - return ( - - 0} - /> - - ); -} - export function AppSidebar({ ...props }: React.ComponentProps) { const emphasis = useMessageListEmphasis(); const { data: currentUser } = useQuery(currentUserQueryOptions()); const signOut = useSignOut(); const signOutOffered = useSignOutOffered(); const channels = useInfiniteQuery(channelListQueryOptions()); + // The folders, in the same breath as the roster: the Projects section is drawn from this read and + // each channel's own `projectId`, so no row is asked about separately. + const projects = useQuery(projectsQueryOptions()); + const sidebar = useSidebarSections(); // One socket for the app, opened where the roster is kept live. useChannelEvents(); const [search, setSearch] = useState(""); const scrollRoot = useRef(null); const searching = search.trim().length > 0; - const visibleChannels = pinnedFirst(matchingChannels(channels.data, search)); + const visibleChannels = matchingChannels(channels.data, search); /* * FILTERING DOES NOT ANIMATE. Rows exit and relayout on every keystroke otherwise, which is a * list thrashing under somebody who is still typing — and the moving target is the very thing @@ -228,6 +107,14 @@ export function AppSidebar({ ...props }: React.ComponentProps) { !searching && (channels.data?.length ?? 0) <= MAX_ANIMATED_ROWS; const navigation = useNavigationItems(); const { setOpenMobile } = useSidebar(); + const projectsCache = projects.data ?? []; + /* + * Whether there is anything to section at all. Nothing is drawn until the roster has answered, so + * a slow read shows a quiet sidebar rather than three sections announcing they are empty; and a + * person with no conversations and no folders keeps the one card that tells them how to start. + */ + const empty = + channels.data?.length === 0 && projectsCache.length === 0 && !searching; return ( @@ -306,7 +193,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ) : null} - {!searching && channels.data?.length === 0 ? ( + {empty ? (
@@ -318,16 +205,17 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
) : null} - - {visibleChannels.map((channel) => ( - - ))} - + {!empty && channels.data !== undefined ? ( + + ) : null} (null); + const [problem, setProblem] = useState(null); return ( <> @@ -94,6 +104,13 @@ export const Channel = memo(function Channel({ } params={{ channelId }} type="button" + // A row carries its channel, so a folder's header can be dropped on. Dragging is the + // pointer's way in; the menu and the panel beside an open conversation are the rest. + draggable + onDragStart={(event) => { + event.dataTransfer.setData(DRAGGED_CHANNEL_MIME, channelId); + event.dataTransfer.effectAllowed = "move"; + }} className="flex flex-row py-2 px-2.5 gap-2 items-center w-full hover:bg-foreground/5 rounded-lg [contain-intrinsic-size:auto_3.25rem] [content-visibility:auto]" activeProps={{ className: "bg-foreground/5", @@ -116,16 +133,24 @@ export const Channel = memo(function Channel({ { - setPinProblem(null); + setProblem(null); setPinned.mutate( { channelId, pinned: !pinned }, - { onError: (thrown) => setPinProblem(thrown.message) }, + { onError: (thrown) => setProblem(thrown.message) }, ); }} > {pinned ? : } {pinned ? "Unpin conversation" : "Pin conversation"} + {/* Filing is this person's own; the folders offered are the ones they own, and the row + only exists because they are a member of the conversation. */} + {canRename ? ( setRenaming(true)}> @@ -141,9 +166,9 @@ export const Channel = memo(function Channel({ - {pinProblem ? ( + {problem ? (

- {pinProblem} + {problem}

) : null} + isUnread(channel, (params as { channelId?: string }).channelId), + }); + return ( + + 0} + projectId={channel.projectId ?? null} + projects={projects} + /> + + ); +} + +/** The rows of one section, or the line that stands in for them while it holds none. */ +function SectionRows({ + channels, + projects, + emphasis, + animateOrder, + emptyText, + emptyHint, +}: { + channels: ChannelSummary[]; + projects: Project[]; + emphasis: MessageListEmphasis; + animateOrder: boolean; + emptyText: string; + emptyHint?: string; +}) { + if (channels.length === 0) { + return ; + } + return ( + + {pinnedFirst(channels).map((channel) => ( + + ))} + + ); +} + +/** One of Group and Direct: a header, and the conversations membership puts there. */ +function MembershipSection({ + id, + channels, + collapsed, + onToggle, + searching, + projects, + emphasis, + animateOrder, +}: { + id: "group" | "direct"; + channels: ChannelSummary[]; + collapsed: boolean; + onToggle: () => void; + searching: boolean; + projects: Project[]; + emphasis: MessageListEmphasis; + animateOrder: boolean; +}) { + if (!sectionVisible(channels.length, searching)) return null; + const bodyId = `sidebar-section-${id}`; + return ( +
+ + +
+ ); +} + +/** + * One folder's header, and what is filed in it. + * + * Folded on its own, without being remembered: the section's fold is the one a person asked to keep + * across devices, and a folder inside it is a detail of the moment they are looking at it. Rename is + * inline — the name is all a folder has — and Delete is confirmed by a dialog that says the + * conversations stay. + */ +function FolderBlock({ + folder, + projects, + emphasis, + animateOrder, +}: { + folder: Folder; + projects: Project[]; + emphasis: MessageListEmphasis; + animateOrder: boolean; +}) { + const queryClient = useQueryClient(); + const rename = useMutation(renameProjectMutationOptions(queryClient)); + /* + * The other way in: a row dragged from anywhere in the sidebar and dropped on this folder. + * Filed through `mutate` rather than the promise because this component stays on screen — the + * folder is not closed by the drop the way the menu is closed by the click, so the observer is + * still listening when the server answers. + */ + const move = useMutation(moveChannelToProjectMutationOptions(queryClient)); + /* Whether a row this folder accepts is over it, for the highlight a drop target owes. */ + const [droppable, setDroppable] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const [renaming, setRenaming] = useState(false); + const [confirming, setConfirming] = useState(false); + const bodyId = `sidebar-project-${folder.project.id}`; + + return ( +
+ + +
+ {renaming ? ( +
+ { + setRenaming(false); + rename.reset(); + }} + onSubmit={(name) => { + rename.mutate( + { projectId: folder.project.id, name }, + { onSuccess: () => setRenaming(false) }, + ); + }} + pending={rename.isPending} + placeholder="Project name" + problem={rename.error?.message} + submitLabel="Rename project" + /> +
+ ) : ( + + )} +
+
+ + setRenaming(true)}> + + Rename project… + + setConfirming(true)} + variant="destructive" + > + + Delete project… + + +
+ {/* No toast in this app: why a drop did not take is said on the folder it was dropped on. */} + {move.error ? ( +

+ {move.error.message} +

+ ) : null} + + +
+ ); +} + +/** + * The Projects section: the "+" that makes a folder, each folder, and the hint while there are none. + * + * The new folder is named where it will be drawn — an inline box under the header — rather than in + * a dialog, so the person types the name in the place the row appears. Escape leaves nothing behind. + */ +function ProjectsSection({ + folders, + projects, + collapsed, + onToggle, + searching, + emphasis, + animateOrder, +}: { + folders: Folder[]; + projects: Project[]; + collapsed: boolean; + onToggle: () => void; + searching: boolean; + emphasis: MessageListEmphasis; + animateOrder: boolean; +}) { + const queryClient = useQueryClient(); + const create = useMutation(createProjectMutationOptions(queryClient)); + const [creating, setCreating] = useState(false); + const bodyId = "sidebar-section-projects"; + const conversationCount = folders.reduce( + (total, folder) => total + folder.channels.length, + 0, + ); + /* + * While searching, a folder is drawn only if what is filed in it matched: an empty folder beside + * the box would be a heading for results that do not exist, and the ones that do are the answer. + */ + const shown = searching + ? folders.filter((folder) => folder.channels.length > 0) + : folders; + + if (!sectionVisible(conversationCount, searching) && !creating) return null; + + return ( +
+ setCreating(true)} + type="button" + > + + + } + bodyId={bodyId} + collapsed={collapsed} + count={conversationCount} + onToggle={onToggle} + title={SECTION_TITLES.projects} + /> + +
+ ); +} + +export function ConversationSections({ + channels, + projects, + collapsed, + onToggle, + searching, + emphasis, + animateOrder, +}: { + /** The roster, already narrowed by the search box. */ + channels: ChannelSummary[]; + projects: Project[]; + /** Which sections this person folded, as their account has it. */ + collapsed: SidebarSections; + onToggle: (id: SidebarSectionId) => void; + searching: boolean; + emphasis: MessageListEmphasis; + animateOrder: boolean; +}) { + const sections = conversationSections(channels, projects); + // A search opens what it matched: a result inside a folded section is an answer the person was + // told about and not shown. The fold they chose is drawn over again as soon as the box is empty. + const folded = collapsedSections(collapsed, searching); + + return ( + <> + onToggle("group")} + projects={projects} + searching={searching} + /> + onToggle("direct")} + projects={projects} + searching={searching} + /> + onToggle("projects")} + projects={projects} + searching={searching} + /> + + ); +} diff --git a/app/src/components/app-sidebar/delete-project-dialog.tsx b/app/src/components/app-sidebar/delete-project-dialog.tsx new file mode 100644 index 0000000000..a8bb5fe244 --- /dev/null +++ b/app/src/components/app-sidebar/delete-project-dialog.tsx @@ -0,0 +1,74 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { deleteProjectMutationOptions } from "@/lib/projects/mutations"; +import type { Project } from "@/lib/projects/queries"; + +/** + * Deletes a folder, and says what that does not do. + * + * Confirmed rather than immediate, because the row it was invoked on is one of several + * identical-looking folder rows — but the sentence matters more than the confirmation: a person + * deleting a folder has to be able to see that their conversations are not going with it. + */ +export function DeleteProjectDialog({ + project, + open, + onOpenChange, +}: { + project: Project; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const queryClient = useQueryClient(); + const remove = useMutation(deleteProjectMutationOptions(queryClient)); + + return ( + + + + Delete “{project.name}”? + + The folder goes. Its conversations stay, back under Group chats or + Direct chats with their messages. + + + {remove.error ? ( + +

+ {remove.error.message} +

+
+ ) : null} + + + + +
+
+ ); +} diff --git a/app/src/components/app-sidebar/move-to-project-menu.tsx b/app/src/components/app-sidebar/move-to-project-menu.tsx new file mode 100644 index 0000000000..5ef3a74d61 --- /dev/null +++ b/app/src/components/app-sidebar/move-to-project-menu.tsx @@ -0,0 +1,100 @@ +import { IconFolderMinus, IconFolderPlus, IconPlus } from "@tabler/icons-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Fragment, useState } from "react"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@/components/ui/context-menu"; +import { moveToProjectOffers } from "@/lib/channels/sections"; +import { moveChannelToProjectMutationOptions } from "@/lib/projects/mutations"; +import type { Project } from "@/lib/projects/queries"; +import { NewProjectDialog } from "./new-project-dialog"; + +/** + * "Move to project ▸" and "Remove from project", for one conversation's menu. + * + * Only ever the caller's own folders, because the folder list came from `/api/projects`, which is + * owner-scoped; and only ever on a row the roster drew, which it draws for members. So what the + * server enforces — owner files, member moves — is what this menu can offer. + * + * A refused move is said on the row that asked, like a refused pin: the menu has closed by then and + * a conversation that quietly stayed where it was reads as a click that did nothing. + */ +export function MoveToProjectMenu({ + channelId, + currentProjectId, + projects, + onProblem, +}: { + channelId: string; + /** The folder this conversation is filed in, or null. Decides whether "Remove" is offered. */ + currentProjectId: string | null; + projects: Project[]; + onProblem: (problem: string) => void; +}) { + const queryClient = useQueryClient(); + const move = useMutation(moveChannelToProjectMutationOptions(queryClient)); + const [creating, setCreating] = useState(false); + const offers = moveToProjectOffers(projects, currentProjectId); + + /* + * Filed through the promise, not through the mutation's per-call callbacks: the click that asks + * for the move also closes the menu, and this hook lives in that menu. TanStack Query delivers + * `mutate`'s callbacks through the observer, and an unmounted observer has no listeners left — so + * a refused move reported that way was reported to nobody and the row said nothing. The promise + * settles whether or not this component is still on screen, and `onProblem` belongs to the row. + */ + const file = (projectId: string | null) => + move + .mutateAsync({ channelId, projectId }) + .catch((error: Error) => onProblem(error.message)); + + return ( + <> + + + + Move to project + + + {offers.map((offer) => + offer.kind === "project" ? ( + file(offer.projectId)} + > + {offer.name} + + ) : ( + + {/* The way to a folder that does not exist yet reads as its own choice, not as + one of the folders. */} + + setCreating(true)}> + + New project… + + + ), + )} + + + {currentProjectId ? ( + file(null)}> + + Remove from project + + ) : null} + {/* The folder is made first, then this conversation is filed into it — the row stays in the + menu it was opened from while the dialog explains itself. */} + file(project.id)} + onOpenChange={setCreating} + open={creating} + /> + + ); +} diff --git a/app/src/components/app-sidebar/new-project-dialog.tsx b/app/src/components/app-sidebar/new-project-dialog.tsx new file mode 100644 index 0000000000..4dbc405101 --- /dev/null +++ b/app/src/components/app-sidebar/new-project-dialog.tsx @@ -0,0 +1,117 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { createProjectMutationOptions } from "@/lib/projects/mutations"; +import type { Project } from "@/lib/projects/queries"; + +/** + * Makes a folder from wherever the person is. + * + * The Projects section has its own inline box beside the "+", which is the usual way in. This one + * exists for the other way: a conversation's menu, where the folder the conversation should go into + * does not exist yet, and leaving the menu to make it first would lose the row that was right + * there. `onCreated` is what files the conversation into the new folder without a second trip. + */ +export function NewProjectDialog({ + open, + onOpenChange, + onCreated, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated?: (project: Project) => void; +}) { + const formId = useId(); + const queryClient = useQueryClient(); + const create = useMutation(createProjectMutationOptions(queryClient)); + const [draft, setDraft] = useState(""); + + const save = async () => { + let project: Project; + try { + project = await create.mutateAsync(draft); + } catch { + // The failure is on the mutation and drawn below; staying open says "not done". + return; + } + setDraft(""); + create.reset(); + onOpenChange(false); + onCreated?.(project); + }; + + return ( + { + if (!next) { + setDraft(""); + create.reset(); + } + onOpenChange(next); + }} + open={open} + > + + + New project + + A folder for conversations of your own. Nobody else sees it, and + moving a conversation into it changes nothing about who can open it. + + + +
{ + event.preventDefault(); + if (!create.isPending) void save(); + }} + > + + Name + setDraft(event.target.value)} + placeholder="Website redesign" + value={draft} + /> + +
+ {create.error ? ( +

+ {create.error.message} +

+ ) : null} +
+ + + + +
+
+ ); +} diff --git a/app/src/components/app-sidebar/project-name-input.tsx b/app/src/components/app-sidebar/project-name-input.tsx new file mode 100644 index 0000000000..1d331caf74 --- /dev/null +++ b/app/src/components/app-sidebar/project-name-input.tsx @@ -0,0 +1,85 @@ +import { useId, useState } from "react"; +import { Input } from "@/components/ui/input"; + +/** + * A project name typed where the project will be drawn. + * + * Inline rather than in a dialog: the Projects section is where the new folder appears, so the box + * that names it sits in the same place, and Enter creates it without the person having to find a + * Save button. Escape, or clicking away, leaves nothing behind. + * + * The same box renames an existing folder — one component, because it is one interaction, and a + * name is the only thing a project has to edit. + */ +export function ProjectNameInput({ + initialName = "", + placeholder, + submitLabel, + pending, + problem, + onSubmit, + onCancel, +}: { + initialName?: string; + placeholder: string; + /** Said on the submit button's screen-reader label, so the two uses of this box stay distinct. */ + submitLabel: string; + pending: boolean; + /** What the server said when the last attempt failed, if it did. */ + problem?: string | null; + onSubmit: (name: string) => void; + onCancel: () => void; +}) { + const inputId = useId(); + const [draft, setDraft] = useState(initialName); + const submit = () => { + const name = draft.replace(/\s+/g, " ").trim(); + if (name.length === 0) { + onCancel(); + return; + } + onSubmit(name); + }; + + return ( +
{ + event.preventDefault(); + if (!pending) submit(); + }} + > + + setDraft(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + }} + placeholder={placeholder} + value={draft} + /> + {problem ? ( +

+ {problem} +

+ ) : null} + {/* Kept out of the tab order on purpose: Enter and Escape are the whole interaction, and a + visible pair of buttons in a 28px row would be wider than the row is tall. */} + +
+ ); +} diff --git a/app/src/components/app-sidebar/section-header.tsx b/app/src/components/app-sidebar/section-header.tsx new file mode 100644 index 0000000000..5425df0e80 --- /dev/null +++ b/app/src/components/app-sidebar/section-header.tsx @@ -0,0 +1,81 @@ +import { IconChevronRight } from "@tabler/icons-react"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +/** + * A sidebar section's header row: chevron, title, and how many conversations it holds. + * + * The whole row is the toggle, and it is a button rather than a link so a folded section is still + * reachable by keyboard and says so to a screen reader (`aria-expanded`). The chevron points the way + * the section opens rather than naming the state, which is the one animation this row has. + */ +export function SectionHeader({ + bodyId, + title, + count, + collapsed, + onToggle, + action, +}: { + /** The id of the list this header folds, so `aria-controls` names something that exists. */ + bodyId: string; + title: string; + count: number; + collapsed: boolean; + onToggle: () => void; + /** Sits beside the title and does not toggle anything, such as the Projects section's "+". */ + action?: React.ReactNode; +}) { + return ( +
+ + {action} +
+ ); +} + +/** + * What an empty section says in the place a conversation would be. + * + * Plain text rather than the app's `Empty` card: three of those stacked down the sidebar would be + * three framed boxes where the person expected their conversations, and this is a line about one + * section, not a screen about the whole roster. + */ +export function SectionEmpty({ + text, + hint, +}: { + text: string; + /** One sentence about how a conversation gets here. The Projects hint is the only one today. */ + hint?: string; +}) { + return ( +

+ {text} + {hint ? ( + + {hint} + + ) : null} +

+ ); +} diff --git a/app/src/components/channels/conversation-actions.tsx b/app/src/components/channels/conversation-actions.tsx index 9d8097387b..48c01c53ba 100644 --- a/app/src/components/channels/conversation-actions.tsx +++ b/app/src/components/channels/conversation-actions.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { setChannelPinnedMutationOptions } from "@/lib/channels/mutations"; import { channelListQueryOptions } from "@/lib/channels/queries"; import { DeleteConversationDialog } from "./delete-conversation-dialog"; +import { MoveToProjectButton } from "./move-to-project-button"; /** * Pin and Delete for the conversation that is open, in the Bot panel beside it. @@ -61,6 +62,7 @@ export function ConversationActions({ {pinned ? : } {pinned ? "Unpin" : "Pin"} + + + + + Move to project + + Your folders, and only yours: where this conversation sits is + yours alone, and everybody else files it as they like. + + + + {offers.map((offer) => + offer.kind === "project" ? ( + + ) : ( + + ), + )} + {filedIn ? ( + + ) : null} + {move.error ? ( +

+ {move.error.message} +

+ ) : null} +
+
+
+ {/* The folder is made first, then this conversation is filed into it. */} + file(project.id)} + onOpenChange={setCreating} + open={creating} + /> + + ); +} + +/** + * The folder this conversation is filed in, or null for none, off the sidebar's paged list. + * + * The list's first page always holds every pinned conversation, and a conversation outside the + * pages loaded so far cannot be pinned — but it can be filed anywhere, so "not in the pages yet" + * means "unknown" and is drawn as unfiled rather than asserted as fact. The same hedge the pin + * beside this button makes. + */ +function useInfiniteQueryFiledProject(channelId: string) { + return useInfiniteQuery({ + ...channelListQueryOptions(), + select: (data) => { + for (const page of data.pages) { + const channel = page.channels.find((row) => row.id === channelId); + if (channel) return channel.projectId ?? null; + } + return null; + }, + }).data; +} diff --git a/app/src/lib/channels/order.ts b/app/src/lib/channels/order.ts new file mode 100644 index 0000000000..13009b204a --- /dev/null +++ b/app/src/lib/channels/order.ts @@ -0,0 +1,55 @@ +/** + * How the roster is narrowed and ordered before a section draws it. + * + * Both rules live here rather than in the sidebar component because the sidebar is not the only + * surface that draws a roster — the skins' own sheets do too — and because the sidebar now applies + * them per section: each of Group, Direct and every project holds its own pinned-first, newest + * activity next. `app-sidebar.tsx` re-exports both, so what already imported them from there keeps + * working. + */ +import type { ChannelSummary } from "./queries"; + +/** + * The roster, narrowed to what the person typed. + * + * Matches the channel's name, its summary, its opening and the last message, because those are the + * things the row can actually show — searching against something invisible returns results a person + * cannot account for. The opening and the last message are included because one of them is what the + * second line draws until the conversation has been named. Message history beyond that line is not + * here to search: it lives in the thread store, and reaching for it is a server endpoint rather than + * a filter. + * + * An empty query returns the input array unchanged rather than a copy, so typing and clearing does + * not hand `AnimatePresence` a new array identity and restage the whole list. + */ +export function matchingChannels( + channels: ChannelSummary[] | undefined, + query: string, +): ChannelSummary[] { + if (!channels) { + return []; + } + const needle = query.trim().toLowerCase(); + if (!needle) { + return channels; + } + return channels.filter((channel) => + [channel.name, channel.summary, channel.opening, channel.lastMessage].some( + (field) => field?.toLowerCase().includes(needle), + ), + ); +} + +/** + * Pinned channels first, everything else after, newest activity first within each group. + * + * The mirror of a server rule, not the rule itself: the roster query orders pinned-first and its + * cursor carries the pin, so a pinned channel arrives on page one however long ago it was last + * spoken in. Sorting here as well is for what happens between refetches — the socket patches a pin + * onto a loaded row without moving it, and re-sorts a page by recency alone — which is the same + * reason `byRecency` in use-channel-events.ts mirrors the recency rule. A stable partition, so the + * recency order inside each group is whatever arrived. + */ +export function pinnedFirst(channels: ChannelSummary[]): ChannelSummary[] { + return [...channels].sort((a, b) => Number(b.pinned) - Number(a.pinned)); +} diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts index f19df7ed7a..b9a17172ae 100644 --- a/app/src/lib/channels/queries.ts +++ b/app/src/lib/channels/queries.ts @@ -103,6 +103,21 @@ export type ChannelSummary = AgentChannel & { * check-in. Absent from a server that predates tasks, which reads as not one. */ task?: boolean; + /** + * How many people are in this conversation, the caller included. + * + * What tells a group chat from a 1:1 without asking the server about each row: more than one + * person is shared work whatever the Bot count. Absent from a server that predates projects, + * which reads as one — the shape a 1:1 has. + */ + memberCount?: number; + /** + * The caller's own project folder holding this conversation, or null for none. + * + * Per member, like the pin: two people in the same channel may have filed it in different + * folders, or in none. Absent from a server that predates projects, which reads as unfiled. + */ + projectId?: string | null; }; export const channelKeys = { diff --git a/app/src/lib/channels/sections.ts b/app/src/lib/channels/sections.ts new file mode 100644 index 0000000000..a5eeb1eaa6 --- /dev/null +++ b/app/src/lib/channels/sections.ts @@ -0,0 +1,218 @@ +/** + * Which sidebar section a conversation belongs to, and how the three sections fold. + * + * Pure, and apart from the components that draw them, because the rule is the feature: a channel + * filed in one of this person's projects is drawn there and nowhere else, everything else is Group + * or Direct by its own shape, and a search reaches all three at once. The sidebar reads the answer + * and renders it; nothing here fetches, and nothing here asks the server a question per row — every + * input is a field the roster and the projects read already carry. + */ +import type { + SidebarSectionId, + SidebarSections, +} from "@openbot/shared/user-preferences"; +import type { ChannelSummary } from "@/lib/channels/queries"; +import type { Project } from "@/lib/projects/queries"; + +/** The three sections, in the order the sidebar draws them. */ +/** + * The mime type a dragged conversation row carries, so a folder accepts only our rows and not + * whatever else a browser might drop on it. A private handshake between the two halves of this + * feature, not a contract anyone else reads. + */ +export const DRAGGED_CHANNEL_MIME = "application/x-openbot-channel"; + +export const CONVERSATION_SECTION_IDS = [ + "group", + "direct", + "projects", +] as const satisfies readonly SidebarSectionId[]; + +export const SECTION_TITLES: Record = { + group: "Group chats", + direct: "Direct chats", + projects: "Projects", +}; + +/** What an empty section says, in the row the section would otherwise hold conversations in. */ +export const SECTION_EMPTY: Record = { + group: "No group chats yet", + direct: "No direct chats yet", + projects: "No projects yet", +}; + +/** + * What an empty section means, for the person looking at it. + * + * Short on purpose: the section header already says which conversations are missing, so this line + * only has to say how one gets there. The Projects one carries the feature's whole idea, because a + * person who has never made a folder is the one who needs to read it. + */ +export const SECTION_EMPTY_HINT: Record = { + group: + "A conversation with more than one Bot, or with somebody else in it, appears here.", + direct: "Your one-to-one conversations appear here.", + projects: + "Folders of your own: make one with +, then file a conversation into it from its menu.", +}; + +/** + * A group chat, by the only two shapes the sidebar can see. + * + * More than one person (the caller included, which is what `memberCount` counts) or more than one + * Bot. Either one alone is a group: two people and one Bot is shared work, one person and two Bots + * is a room. Absent fields read as a 1:1, which is what they were before the server sent them. + */ +export function isGroupChat(channel: ChannelSummary): boolean { + return (channel.memberCount ?? 1) > 1 || channel.agentIds.length > 1; +} + +export type ProjectFolder = { + project: Project; + /** The conversations filed here, in the order they arrived. */ + channels: ChannelSummary[]; +}; + +export type ConversationSections = { + group: ChannelSummary[]; + direct: ChannelSummary[]; + /** The person's folders, in their own order, each with what is filed in it. */ + projects: ProjectFolder[]; +}; + +/** + * Every conversation, placed once. + * + * A channel whose `projectId` names a folder this person no longer has — a folder deleted in another + * tab, a roster read that outran the projects read — falls back to Group or Direct rather than + * disappearing: a conversation nobody can find is worse than one in the section it used to be in. + * Order is preserved inside each section, because the roster arrives in the order the sidebar draws + * (pinned first, then most recent activity) and re-sorting here would fight it. + */ +export function conversationSections( + channels: ChannelSummary[] | undefined, + projects: Project[] | undefined, +): ConversationSections { + const folders = new Map(); + for (const project of projects ?? []) { + folders.set(project.id, { project, channels: [] }); + } + + const sections: ConversationSections = { + group: [], + direct: [], + projects: [...folders.values()], + }; + + for (const channel of channels ?? []) { + const folder = channel.projectId + ? folders.get(channel.projectId) + : undefined; + if (folder) { + folder.channels.push(channel); + } else if (isGroupChat(channel)) { + sections.group.push(channel); + } else { + sections.direct.push(channel); + } + } + + return sections; +} + +/** How many conversations a section header counts. Projects counts what is filed in all of them. */ +export function sectionConversationCount( + sections: ConversationSections, + id: SidebarSectionId, +): number { + switch (id) { + case "group": + return sections.group.length; + case "direct": + return sections.direct.length; + case "projects": + return sections.projects.reduce( + (total, folder) => total + folder.channels.length, + 0, + ); + } +} + +/** + * The stored flags, as a complete set of booleans. + * + * A missing flag is open, which is what a person sees before they fold anything and what a row + * written by a build that knew fewer sections has to mean for the one it does not carry. + */ +export function mergeSidebarSections( + stored: Partial> | undefined, +): SidebarSections { + return { + group: stored?.group === true, + direct: stored?.direct === true, + projects: stored?.projects === true, + }; +} + +/** One header click. The whole object is sent on save, so this returns the whole object. */ +export function toggledSidebarSection( + current: SidebarSections, + id: SidebarSectionId, +): SidebarSections { + return { ...current, [id]: !current[id] }; +} + +/** + * What is folded, given what the person asked for and what they are typing. + * + * A search opens every section: a match inside a section somebody had folded is a result they would + * otherwise be told about and not shown. Search is temporary, so this is drawn over the stored + * flags rather than written into them — the fold they chose is still there when the box is cleared. + */ +export function collapsedSections( + stored: SidebarSections, + searching: boolean, +): SidebarSections { + if (!searching) return stored; + return { group: false, direct: false, projects: false }; +} + +/** + * Whether a section is drawn at all under the current search. + * + * Folded sections are still drawn — the header is how they are opened again. A section with nothing + * in it is only dropped while searching, where "No group chats yet" beside a box full of a + * colleague's name would be the wrong sentence about the wrong thing. + */ +export function sectionVisible(count: number, searching: boolean): boolean { + return count > 0 || !searching; +} + +/** One row of a conversation's "Move to project ▸" submenu. */ +export type MoveOffer = + | { kind: "project"; projectId: string; name: string } + | { kind: "new" }; + +/** + * What the submenu offers: every folder but the one the conversation is already in, then the way to + * make another. + * + * The current folder is left out rather than drawn as a disabled row — moving something to where it + * already is is not an action, and the menu already has "Remove from project" for the way back out. + * All of these are this person's own folders, and the row was drawn from a roster only a member can + * see, so ownership and membership are what got the row here in the first place. + */ +export function moveToProjectOffers( + projects: Project[] | undefined, + currentProjectId: string | null | undefined, +): MoveOffer[] { + const offers: MoveOffer[] = (projects ?? []) + .filter((project) => project.id !== currentProjectId) + .map((project) => ({ + kind: "project" as const, + projectId: project.id, + name: project.name, + })); + offers.push({ kind: "new" }); + return offers; +} diff --git a/app/src/lib/channels/unread.ts b/app/src/lib/channels/unread.ts new file mode 100644 index 0000000000..4950136dda --- /dev/null +++ b/app/src/lib/channels/unread.ts @@ -0,0 +1,32 @@ +/** + * Whether a row has something to tell the person looking at it. + * + * Here rather than in the sidebar component because the sidebar is not the only surface that draws + * a roster, and because the three sections ask the same question of every row they hold. + * `app-sidebar.tsx` re-exports both, so what already imported them from there keeps working. + */ +import type { ChannelSummary } from "./queries"; + +/** + * Whether a Bot has said something this member has not had on screen yet. + * + * A Bot's message, and only a Bot's: your own message carries a null agent id and reading your own + * words needs no marker. ISO-8601 strings compare correctly as strings, which is the same bet the + * server's recency sort already makes. + */ +export function hasUnseenActivity(channel: ChannelSummary): boolean { + if (channel.lastMessageAgentId === null || channel.lastMessageAt === null) { + return false; + } + return ( + channel.lastReadAt === null || channel.lastMessageAt > channel.lastReadAt + ); +} + +/** Unseen activity somewhere you are not looking. The open channel never shows the dot. */ +export function isUnread( + channel: ChannelSummary, + openChannelId: string | undefined, +): boolean { + return channel.id !== openChannelId && hasUnseenActivity(channel); +} diff --git a/app/src/lib/projects/mutations.ts b/app/src/lib/projects/mutations.ts new file mode 100644 index 0000000000..c6ced2804b --- /dev/null +++ b/app/src/lib/projects/mutations.ts @@ -0,0 +1,87 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { channelKeys } from "@/lib/channels/queries"; +import { client } from "@/lib/client"; +import { type Project, projectKeys } from "./queries"; + +/** + * What a write to a folder changes about the two reads the sidebar draws from. + * + * Both, every time: the projects read owns the folder and its filing, and the roster read owns each + * channel's `projectId` — the same fact written in two places, so a move that refetched only one of + * them would draw a conversation in both sections or in neither. + */ +function refreshSidebar(queryClient: QueryClient) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: projectKeys.all }), + queryClient.invalidateQueries({ queryKey: channelKeys.all }), + ]); +} + +export function createProjectMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: (name: string): Promise => + client("/api/projects", "project", { + method: "POST", + body: { name }, + fallback: "Couldn't create that project.", + }), + onSuccess: () => refreshSidebar(queryClient), + }); +} + +export function renameProjectMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: (variables: { + projectId: string; + name: string; + }): Promise => + client(`/api/projects/${variables.projectId}`, "project", { + method: "PATCH", + body: { name: variables.name }, + fallback: "Couldn't rename that project.", + }), + onSuccess: () => refreshSidebar(queryClient), + }); +} + +/** + * Deletes the folder only. + * + * The channels inside it are untouched on the server — the filing rows cascade with the project and + * nothing else does — so the sidebar's next read draws them back under Group or Direct. The roster + * is refetched for that reason, not because anything about a conversation changed. + */ +export function deleteProjectMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (projectId: string): Promise => { + await client(`/api/projects/${projectId}`, { + method: "DELETE", + fallback: "Couldn't delete that project.", + }); + }, + onSuccess: () => refreshSidebar(queryClient), + }); +} + +/** + * Files one conversation into a folder of this person's, or takes it out when `projectId` is null. + * + * The server refuses a channel the caller is not in and a folder that is not theirs, with the same + * 404 a conversation they cannot see already answers with, so the row shows the server's sentence + * rather than guessing which of the two it was. + */ +export function moveChannelToProjectMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + channelId: string; + projectId: string | null; + }): Promise => { + await client(`/api/channels/${variables.channelId}/project`, { + method: "PATCH", + body: { projectId: variables.projectId }, + fallback: "Couldn't move that conversation.", + }); + }, + onSuccess: () => refreshSidebar(queryClient), + }); +} diff --git a/app/src/lib/projects/queries.ts b/app/src/lib/projects/queries.ts new file mode 100644 index 0000000000..3cdcfd8b9e --- /dev/null +++ b/app/src/lib/projects/queries.ts @@ -0,0 +1,45 @@ +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +/** + * One of this person's project folders, as the browser sees it. + * + * `channelIds` rather than titles, deliberately: the folder is drawn from the roster the sidebar + * already holds, so the ids are enough to file rows, and a payload that carried names could name a + * conversation to somebody who cannot open it. + */ +export type Project = { + id: string; + name: string; + /** Where the folder sits in the Projects section. Ties fall back to `createdAt`. */ + sortOrder: number; + /** ISO-8601. */ + createdAt: string; + /** ISO-8601. */ + updatedAt: string; + /** The conversations this person has filed here, in no particular order. */ + channelIds: string[]; +}; + +export const projectKeys = { + all: ["projects"] as const, + list: () => ["projects", "list"] as const, +}; + +/** + * This person's folders. + * + * One read for the whole sidebar: the section, every folder in it, and which conversations are + * filed where. A server that predates projects answers 404 and the query settles into an error, + * which the sidebar draws as an empty Projects section rather than as a broken one. + */ +export function projectsQueryOptions() { + return queryOptions({ + queryKey: projectKeys.list(), + queryFn: ({ signal }): Promise => + client("/api/projects", "projects", { + signal, + fallback: "Couldn't load your projects.", + }), + }); +} diff --git a/app/src/lib/settings/sidebar-sections.ts b/app/src/lib/settings/sidebar-sections.ts new file mode 100644 index 0000000000..9b375ded30 --- /dev/null +++ b/app/src/lib/settings/sidebar-sections.ts @@ -0,0 +1,82 @@ +import type { + SidebarSectionId, + SidebarSections, + UserPreferences, +} from "@openbot/shared/user-preferences"; +import { + mutationOptions, + type QueryClient, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { + mergeSidebarSections, + toggledSidebarSection, +} from "@/lib/channels/sections"; +import { + useUserPreferences, + userPreferencesQueryOptions, +} from "./message-list"; + +/** + * Which sidebar sections this person has folded, saved on their account. + * + * The account and not the browser's storage, for the same reason the skin is: a person who folds + * Projects on their laptop expects it folded on their desktop, and a reload must not change it. + * + * The server takes the three flags as one object — the preference row merges a level deeper for + * exactly this — so every save writes all three. Writing one would replace the others with nothing, + * and every section somebody had folded would spring open. + */ +export function saveSidebarSectionsMutationOptions( + queryClient: QueryClient, + userId: string | undefined, +) { + const queryKey = userPreferencesQueryOptions(userId).queryKey; + return mutationOptions({ + mutationFn: ( + sidebarSections: SidebarSections, + ): Promise => { + if (!userId) throw new Error("Sign in to save your sidebar."); + return client("/api/settings/preferences", "preferences", { + method: "PATCH", + body: { sidebarSections }, + fallback: "Could not save your sidebar", + }); + }, + /* + * Folded on the click, not on the reply: a chevron that waits for a round trip reads as a + * chevron that did not work, and the state it moves to is the one the person just asked for. + * A failed save puts the section back where it was. + */ + onMutate: (sidebarSections) => { + const previous = queryClient.getQueryData(queryKey); + if (previous) + queryClient.setQueryData(queryKey, { ...previous, sidebarSections }); + return { previous }; + }, + onError: (_error, _sections, context) => { + if (context?.previous) + queryClient.setQueryData(queryKey, context.previous); + }, + onSuccess: (preferences) => queryClient.setQueryData(queryKey, preferences), + }); +} + +export function useSidebarSections() { + const { userId, data: preferences } = useUserPreferences(); + const queryClient = useQueryClient(); + const save = useMutation( + saveSidebarSectionsMutationOptions(queryClient, userId), + ); + const sections = mergeSidebarSections(preferences?.sidebarSections); + return { + sections, + /** Whether a section is folded now, as the account has it. */ + collapsed: (id: SidebarSectionId) => sections[id], + toggle: (id: SidebarSectionId) => + save.mutate(toggledSidebarSection(sections, id)), + saving: save.isPending, + }; +} diff --git a/app/tests/app-navigation.test.tsx b/app/tests/app-navigation.test.tsx index 4660a014e0..838d95b428 100644 --- a/app/tests/app-navigation.test.tsx +++ b/app/tests/app-navigation.test.tsx @@ -27,6 +27,7 @@ import { deploymentKeys } from "@/lib/deployment/queries"; import { isNavigationLink, NAVIGATION, navigationFor } from "@/lib/navigation"; import { userPreferencesQueryOptions } from "@/lib/settings/message-list"; import { settleReactWork } from "./settle-react-work"; +import { makeUserPreferences } from "./user-preferences.fixture"; /* * OpenBot's destinations live in one list (`lib/navigation.ts`) that the sidebar and every skin draw @@ -109,13 +110,10 @@ function renderSidebar( } else { queryClient.setQueryData(authKeys.currentUser(), person); } - queryClient.setQueryData(userPreferencesQueryOptions("maya").queryKey, { - messageListEmphasis: "thread", - selfHostBannerDismissed: true, - narration: null, - skin: "openbot", - proactive: "on", - }); + queryClient.setQueryData( + userPreferencesQueryOptions("maya").queryKey, + makeUserPreferences({ selfHostBannerDismissed: true }), + ); queryClient.setQueryData(agentKeys.list(false), []); queryClient.setQueryDefaults(botLifecycleKeys.attention, { staleTime: Number.POSITIVE_INFINITY, diff --git a/app/tests/channel-pagination.test.tsx b/app/tests/channel-pagination.test.tsx index 2b910ef3dc..aa5bee6a69 100644 --- a/app/tests/channel-pagination.test.tsx +++ b/app/tests/channel-pagination.test.tsx @@ -30,7 +30,9 @@ import { botLifecycleKeys } from "@/lib/bot-lifecycle/queries"; import { browserKeys } from "@/lib/browser/queries"; import { type ChannelSummary, channelKeys } from "@/lib/channels/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; +import { projectKeys } from "@/lib/projects/queries"; import { userPreferencesQueryOptions } from "@/lib/settings/message-list"; +import { makeUserPreferences } from "./user-preferences.fixture"; // Keep the sidebar's live-update socket offline; these tests exercise HTTP pagination. class OfflineWebSocket extends EventTarget implements WebSocket { @@ -166,13 +168,10 @@ function renderSidebar() { defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); clients.push(queryClient); - queryClient.setQueryData(userPreferencesQueryOptions("user").queryKey, { - messageListEmphasis: "thread", - selfHostBannerDismissed: false, - narration: null, - skin: "openbot", - proactive: "on", - }); + queryClient.setQueryData( + userPreferencesQueryOptions("user").queryKey, + makeUserPreferences(), + ); queryClient.setQueryData(authKeys.currentUser(), { id: "user", email: "user@example.com", @@ -196,6 +195,9 @@ function renderSidebar() { bots: [], lastPingId: null, }); + // The Projects section is drawn from its own read; seeded empty for the same reason, so only + // channel requests reach `requests`. + queryClient.setQueryData(projectKeys.list(), []); // The Browser button reads whether there is a browser once; answered here so only channel requests are counted. queryClient.setQueryData(browserKeys.state, { available: false, @@ -239,13 +241,10 @@ test("message emphasis updates mounted rows from the account preferences cache", const thread = view.getByText("New conversation"); expect(thread.className).toContain("text-sm"); act(() => - clients[0]?.setQueryData(userPreferencesQueryOptions("user").queryKey, { - messageListEmphasis: "agent", - selfHostBannerDismissed: false, - narration: null, - skin: "openbot", - proactive: "on", - }), + clients[0]?.setQueryData( + userPreferencesQueryOptions("user").queryKey, + makeUserPreferences({ messageListEmphasis: "agent" }), + ), ); await waitFor(() => expect(agent.className).toContain("text-sm")); expect(thread.className).toContain("text-muted-foreground"); diff --git a/app/tests/channel-sections.test.ts b/app/tests/channel-sections.test.ts new file mode 100644 index 0000000000..d8f1160e96 --- /dev/null +++ b/app/tests/channel-sections.test.ts @@ -0,0 +1,256 @@ +import { expect, test } from "bun:test"; +import type { SidebarSections } from "@openbot/shared/user-preferences"; +import type { ChannelSummary } from "../src/lib/channels/queries"; +import { + collapsedSections, + conversationSections, + isGroupChat, + mergeSidebarSections, + moveToProjectOffers, + sectionConversationCount, + sectionVisible, + toggledSidebarSection, +} from "../src/lib/channels/sections"; +import type { Project } from "../src/lib/projects/queries"; + +/* + * Where a conversation goes and how a section folds, without a screen in the way. + * + * `sidebar-conversation-sections.test.tsx` proves the answer reaches the rendered tree; this file + * holds the answer itself, so a rule can be read and corrected without a router, a query client and + * a DOM. The rules are the feature: a channel filed in a folder is drawn there and nowhere else, an + * orphaned file reference falls back rather than vanishing, a search opens what it matched without + * overwriting the fold somebody chose. + */ + +/** A minimal but fully-typed channel summary, so tests build real objects rather than casts. */ +function channel( + id: string, + overrides: Partial = {}, +): ChannelSummary { + return { + id, + name: id, + agentIds: ["bot-1"], + threadId: `thread-${id}`, + active: true, + summary: null, + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + createdAt: "2024-01-01T00:00:00.000Z", + pinned: false, + lastReadAt: null, + ...overrides, + }; +} + +function project(id: string, name: string): Project { + return { + id, + name, + sortOrder: 0, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + channelIds: [], + }; +} + +const OPEN: SidebarSections = { group: false, direct: false, projects: false }; + +test("a second person or a second Bot makes a group, and one of each stays a 1:1", () => { + /* + * The two shapes the sidebar can see, and both are enough on their own: two people sharing one + * Bot is shared work, one person with two Bots is a room. `memberCount` is absent on a server that + * predates projects, and absence has to read as the 1:1 those channels always were rather than + * turning every existing conversation into a group. + */ + expect(isGroupChat(channel("two-people", { memberCount: 2 }))).toBe(true); + expect( + isGroupChat(channel("two-bots", { agentIds: ["bot-1", "bot-2"] })), + ).toBe(true); + expect(isGroupChat(channel("one-on-one", { memberCount: 1 }))).toBe(false); + expect(isGroupChat(channel("old-server"))).toBe(false); + expect(isGroupChat(channel("no-people-yet", { memberCount: 0 }))).toBe(false); +}); + +test("every conversation is placed once, and a filed one is not under Group or Direct", () => { + /* + * The whole point of a folder: the conversation stops being drawn twice. Counts are asserted + * rather than membership alone, because a channel that lands in both its folder and its old + * section is exactly the bug this section of the sidebar exists to remove. + */ + const sections = conversationSections( + [ + channel("group-1", { memberCount: 3 }), + channel("direct-1"), + channel("filed-1", { memberCount: 3, projectId: "p-1" }), + ], + [project("p-1", "Website redesign")], + ); + + expect(sections.group.map((c) => c.id)).toEqual(["group-1"]); + expect(sections.direct.map((c) => c.id)).toEqual(["direct-1"]); + expect(sections.projects.map((folder) => folder.project.id)).toEqual(["p-1"]); + expect(sections.projects[0]?.channels.map((c) => c.id)).toEqual(["filed-1"]); + expect(sectionConversationCount(sections, "group")).toBe(1); + expect(sectionConversationCount(sections, "direct")).toBe(1); + expect(sectionConversationCount(sections, "projects")).toBe(1); +}); + +test("a conversation naming a folder that is gone falls back to its own shape", () => { + /* + * Reachable without anybody doing anything wrong: a folder deleted in another tab, or a roster + * read that outran the projects read. A conversation nobody can find is worse than one sitting in + * the section it used to be in, so the row is placed by shape instead of disappearing. + */ + const sections = conversationSections( + [ + channel("orphan-group", { memberCount: 2, projectId: "p-deleted" }), + channel("orphan-direct", { projectId: "p-deleted" }), + ], + [project("p-1", "Website redesign")], + ); + + expect(sections.group.map((c) => c.id)).toEqual(["orphan-group"]); + expect(sections.direct.map((c) => c.id)).toEqual(["orphan-direct"]); + expect(sections.projects[0]?.channels).toEqual([]); +}); + +test("the roster's order is kept inside a section, and folders keep the person's order", () => { + /* + * The roster arrives in the order the sidebar draws — pinned first, then most recent activity — + * and a folder list arrives in the owner's own sort order. Re-sorting here would fight both, so + * this holds the input order rather than restoring it. + */ + const sections = conversationSections( + [ + channel("recent", { memberCount: 2 }), + channel("older", { memberCount: 2 }), + channel("oldest", { memberCount: 2 }), + channel("filed-late", { projectId: "p-2" }), + channel("filed-early", { projectId: "p-1" }), + ], + [project("p-1", "First"), project("p-2", "Second")], + ); + + expect(sections.group.map((c) => c.id)).toEqual([ + "recent", + "older", + "oldest", + ]); + expect(sections.projects.map((folder) => folder.project.name)).toEqual([ + "First", + "Second", + ]); + expect(sections.projects[0]?.channels.map((c) => c.id)).toEqual([ + "filed-early", + ]); + expect(sections.projects[1]?.channels.map((c) => c.id)).toEqual([ + "filed-late", + ]); +}); + +test("a section with nothing to read is empty rather than absent", () => { + /* + * Both reads can still be in flight, and the sidebar draws the same three headers either way. An + * undefined roster is an empty sidebar, not a thrown render. + */ + const sections = conversationSections(undefined, undefined); + + expect(sections.group).toEqual([]); + expect(sections.direct).toEqual([]); + expect(sections.projects).toEqual([]); + expect(sectionConversationCount(sections, "projects")).toBe(0); +}); + +test("the Projects header counts what is filed across all folders", () => { + const sections = conversationSections( + [ + channel("a", { projectId: "p-1" }), + channel("b", { projectId: "p-1" }), + channel("c", { projectId: "p-2" }), + ], + [project("p-1", "One"), project("p-2", "Two")], + ); + + expect(sectionConversationCount(sections, "projects")).toBe(3); +}); + +test("an absent fold flag reads as open, so a new section is never born folded", () => { + /* + * The stored object is what is *folded*. A person who has folded nothing has no flags, and a row + * written by a build that knew fewer sections carries no flag for the one it did not know — both + * have to draw open, or a section arrives already shut with no way to tell why. + */ + expect(mergeSidebarSections(undefined)).toEqual(OPEN); + expect(mergeSidebarSections({ direct: true })).toEqual({ + group: false, + direct: true, + projects: false, + }); +}); + +test("a header click flips its own section and hands back the whole set", () => { + /* + * The save sends the whole object, so a toggle that returned only the changed flag would write + * the other two away. This is the one place that is checked. + */ + const folded = toggledSidebarSection(OPEN, "direct"); + + expect(folded).toEqual({ group: false, direct: true, projects: false }); + expect(toggledSidebarSection(folded, "direct")).toEqual(OPEN); + expect(toggledSidebarSection(OPEN, "projects")).toEqual({ + group: false, + direct: false, + projects: true, + }); +}); + +test("a search opens every section and leaves the chosen fold in place", () => { + /* + * A match inside a folded section is a result the person would be told about and not shown. The + * fold is drawn over, not written: clearing the box has to bring back what they chose, not the + * all-open state the search needed. + */ + const chosen: SidebarSections = { + group: true, + direct: false, + projects: true, + }; + + expect(collapsedSections(chosen, true)).toEqual(OPEN); + expect(collapsedSections(chosen, false)).toEqual(chosen); +}); + +test("a folded section is still drawn, and an empty one is dropped only while searching", () => { + /* + * The header is how a folded section is opened again, so folding never removes it. An empty one + * is dropped while searching: "No group chats yet" beside a box full of a colleague's name is the + * wrong sentence about the wrong thing. + */ + expect(sectionVisible(0, false)).toBe(true); + expect(sectionVisible(3, false)).toBe(true); + expect(sectionVisible(3, true)).toBe(true); + expect(sectionVisible(0, true)).toBe(false); +}); + +test("the move menu offers every folder but the current one, then the way to make one", () => { + /* + * The current folder is left out rather than drawn disabled: moving something to where it already + * is is not an action, and the menu already carries "Remove from project" for the way back out. + * "New project…" is always last, and is the only row a person with no folders sees. + */ + const folders = [project("p-1", "One"), project("p-2", "Two")]; + + expect(moveToProjectOffers(folders, null)).toEqual([ + { kind: "project", projectId: "p-1", name: "One" }, + { kind: "project", projectId: "p-2", name: "Two" }, + { kind: "new" }, + ]); + expect(moveToProjectOffers(folders, "p-1")).toEqual([ + { kind: "project", projectId: "p-2", name: "Two" }, + { kind: "new" }, + ]); + expect(moveToProjectOffers(undefined, null)).toEqual([{ kind: "new" }]); +}); diff --git a/app/tests/conversation-actions.test.tsx b/app/tests/conversation-actions.test.tsx index 06bdf8aef4..a02e6c8934 100644 --- a/app/tests/conversation-actions.test.tsx +++ b/app/tests/conversation-actions.test.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { ConversationActions } from "@/components/channels/conversation-actions"; import type { ChannelSummary } from "@/lib/channels/queries"; +import type { Project } from "@/lib/projects/queries"; import { settleReactWork } from "./settle-react-work"; /** @@ -24,7 +25,12 @@ afterEach(() => { global.fetch = originalFetch; }); -function summary(id: string, pinned: boolean): ChannelSummary { +/** A minimal but fully-typed channel summary, so the fixtures are real objects rather than casts. */ +function summary( + id: string, + pinned: boolean, + projectId: string | null = null, +): ChannelSummary { return { id, name: "Expenses", @@ -38,16 +44,23 @@ function summary(id: string, pinned: boolean): ChannelSummary { createdAt: "2026-10-01T00:00:00.000Z", pinned, lastReadAt: null, + projectId, }; } const requests: string[] = []; -function serving(channels: ChannelSummary[]) { +function serving(channels: ChannelSummary[], projects: Project[] = []) { requests.length = 0; global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const method = init?.method ?? "GET"; requests.push(`${method} ${String(input)}`); - if (method === "GET") return Response.json({ channels, nextCursor: null }); + if (method === "GET") { + if (String(input).includes("/api/projects")) { + // An envelope, like the server sends: client() unwraps it by key and refuses a bare array. + return Response.json({ projects }); + } + return Response.json({ channels, nextCursor: null }); + } return new Response(null, { status: 204 }); }) as unknown as typeof fetch; } @@ -98,5 +111,55 @@ test("Delete is confirmed, and leaves the conversation before deleting it", asyn fireEvent.click(await view.findByRole("button", { name: "Delete" })); await waitFor(() => expect(requests).toContain("DELETE /api/channels/c1")); - expect(order).toEqual(["leave after 1 requests"]); + // The roster and the folder list, both read before the delete: the panel's Move button + // needs the folders the moment it is drawn. + expect(order).toEqual(["leave after 2 requests"]); +}); + +/* + * "Move to project…". The menu in the sidebar is the fast way in; the panel is the way a person + * already looking at the conversation finds without being told. What these tests say is that the + * panel's button offers the folders and files the open conversation. + */ + +const folders: Project[] = [ + { + id: "p-website", + name: "Website redesign", + sortOrder: 0, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + channelIds: [], + }, +]; + +test("the panel offers Move to project, and picking a folder files the open conversation", async () => { + serving([summary("c1", false)], folders); + const view = draw(); + + fireEvent.click( + await view.findByRole("button", { name: "Move to project…" }), + ); + + fireEvent.click( + await view.findByRole("button", { name: "Website redesign" }), + ); + + await waitFor(() => + expect(requests).toContain("PATCH /api/channels/c1/project"), + ); +}); + +test("a conversation already filed offers the way back out", async () => { + serving([summary("c1", false, "p-website")], folders); + const view = draw(); + + fireEvent.click( + await view.findByRole("button", { name: "Move to project…" }), + ); + expect( + view.getByRole("button", { name: "Remove from project" }), + ).toBeTruthy(); + // The folder it is in is not an action: it is left out rather than drawn disabled. + expect(view.queryByRole("button", { name: "Website redesign" })).toBeNull(); }); diff --git a/app/tests/narrow-sidebar-sheet.test.tsx b/app/tests/narrow-sidebar-sheet.test.tsx index e297530f2c..97f746c417 100644 --- a/app/tests/narrow-sidebar-sheet.test.tsx +++ b/app/tests/narrow-sidebar-sheet.test.tsx @@ -28,6 +28,7 @@ import { type ChannelSummary, channelKeys } from "@/lib/channels/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; import { userPreferencesQueryOptions } from "@/lib/settings/message-list"; import { settleReactWork } from "./settle-react-work"; +import { makeUserPreferences } from "./user-preferences.fixture"; /* * In a narrow window (under 768px) the sidebar is a sheet over the page. Picking a conversation in @@ -117,13 +118,10 @@ function renderNarrowApp(start = "/channel/models") { role: "admin", onboarding: null, }); - queryClient.setQueryData(userPreferencesQueryOptions("dana").queryKey, { - messageListEmphasis: "thread", - selfHostBannerDismissed: true, - narration: null, - skin: "openbot", - proactive: "on", - }); + queryClient.setQueryData( + userPreferencesQueryOptions("dana").queryKey, + makeUserPreferences({ selfHostBannerDismissed: true }), + ); queryClient.setQueryData(agentKeys.list(false), []); queryClient.setQueryDefaults(botLifecycleKeys.attention, { staleTime: Number.POSITIVE_INFINITY, diff --git a/app/tests/self-host-banner.test.tsx b/app/tests/self-host-banner.test.tsx index bd116c2c4e..0e48be4e09 100644 --- a/app/tests/self-host-banner.test.tsx +++ b/app/tests/self-host-banner.test.tsx @@ -14,6 +14,7 @@ import { SelfHostBanner } from "@/components/layout/self-host-banner"; import { authKeys } from "@/lib/auth/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; import { userPreferencesQueryOptions } from "@/lib/settings/message-list"; +import { makeUserPreferences } from "./user-preferences.fixture"; const originalFetch = globalThis.fetch; const clients: QueryClient[] = []; @@ -80,13 +81,9 @@ function setup({ generativeUi: true, selfHostBanner: enabled, }); - const preferences: UserPreferences = { - messageListEmphasis: "thread", + const preferences: UserPreferences = makeUserPreferences({ selfHostBannerDismissed: dismissed, - narration: null, - skin: "openbot", - proactive: "on", - }; + }); client.setQueryData( userPreferencesQueryOptions("alice").queryKey, preferences, diff --git a/app/tests/sidebar-conversation-sections.test.tsx b/app/tests/sidebar-conversation-sections.test.tsx new file mode 100644 index 0000000000..512dd79703 --- /dev/null +++ b/app/tests/sidebar-conversation-sections.test.tsx @@ -0,0 +1,645 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + mock, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import type { SidebarSections } from "@openbot/shared/user-preferences"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { + cleanup, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ConversationSections } from "@/components/app-sidebar/conversation-sections"; +import { agentKeys } from "@/lib/agents/queries"; +import type { ChannelSummary } from "@/lib/channels/queries"; +import type { Project } from "@/lib/projects/queries"; +import { settleReactWork } from "./settle-react-work"; + +/* + * The sidebar's three collapsible sections, and the "Move to project ▸" row in a conversation's + * menu — drawn, folded and acted on. + * + * `lib/channels/sections.ts` decides where a conversation goes and what a fold means, and + * `channel-sections.test.ts` covers that on its own. What only a rendered tree can say is whether + * the answer reaches the screen: a header that names its section and its count, a chevron that asks + * for the section it belongs to, a folded section that hides its rows and keeps its header, a search + * that opens what it matched, a folder that folds without folding the section above it, and a menu + * row that files the conversation it was opened on. + */ + +const OPEN = { + group: false, + direct: false, + projects: false, +} satisfies SidebarSections; + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +/** A minimal but fully-typed channel summary, so the fixtures are real objects rather than casts. */ +function channel( + id: string, + overrides: Partial = {}, +): ChannelSummary { + return { + id, + name: id, + agentIds: ["bot-1"], + threadId: `thread-${id}`, + active: true, + summary: null, + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + createdAt: "2024-01-01T00:00:00.000Z", + pinned: false, + lastReadAt: null, + ...overrides, + }; +} + +function project(id: string, name: string, channelIds: string[] = []): Project { + return { + id, + name, + sortOrder: 0, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + channelIds, + }; +} + +type SeenRequest = { url: string; init: RequestInit | undefined }; + +const realFetch = globalThis.fetch; + +/** + * A fetch that answers the one write these tests ask for and fails everything else loudly. + * + * The roster and the folder list are put in the cache, so a request reaching the network is a bug in + * the fixture rather than something to ignore: answering it would hide the row that went looking. + */ +function servingMove(status = 200, body: unknown = {}) { + const seen: SeenRequest[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + seen.push({ url: String(url), init }); + return new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + return seen; +} + +beforeEach(() => { + servingMove(); +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** + * The sections, in a router and a query client, as `AppSidebar` draws them. + * + * A real memory router rather than a stub: each row is a `Link` and every row asks `useParams` which + * conversation is open, so a row outside a router is a row that cannot render at all. + */ +async function renderSections({ + channels, + projects = [], + collapsed = OPEN, + searching = false, + onToggle = () => {}, +}: { + channels: ChannelSummary[]; + projects?: Project[]; + collapsed?: SidebarSections; + searching?: boolean; + onToggle?: (id: keyof SidebarSections) => void; +}) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }); + queryClient.setQueryData(agentKeys.list(false), []); + + const rootRoute = createRootRoute({ component: Outlet }); + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => ( +
+ +
+ ), + }); + const channelRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/channel/$channelId", + component: () =>
, + }); + const groupRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/group/$channelId", + component: () =>
, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, channelRoute, groupRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + + const view = render( + + + , + ); + // A router resolves its first match on a microtask, so the probe is not in the document yet. + await view.findByTestId("sections"); + return { ...view, queryClient, router }; +} + +/** The header button of a section, which is the whole row and the only toggle in it. */ +function header(container: HTMLElement, title: string): HTMLButtonElement { + const span = within(container).getByText(title); + const button = span.closest("button"); + if (!button) throw new Error(`"${title}" is not drawn in a section header`); + return button as HTMLButtonElement; +} + +/** What a folded section does to the list it hides: the attribute, not the styling. */ +function bodyOf(container: HTMLElement, id: string): HTMLElement { + const body = container.querySelector(`#${CSS.escape(id)}`); + if (!body) throw new Error(`no section body with id "${id}"`); + return body; +} + +/* + * A group chat, a direct chat, and a conversation filed in a folder — the three answers the sidebar + * has to keep apart. The filed one is a group chat by shape on purpose: a folder wins over + * membership, and a channel that is in a project appears nowhere else. + */ +const groupChat = channel("team-room", { + name: "Team room", + agentIds: ["bot-1", "bot-2"], + memberCount: 1, +}); +const directChat = channel("my-assistant", { + name: "My assistant", + agentIds: ["bot-1"], + memberCount: 1, +}); +const filedChat = channel("website-work", { + name: "Website work", + agentIds: ["bot-1", "bot-2"], + memberCount: 3, + projectId: "p-website", +}); +const designFolder = project("p-website", "Website redesign", ["website-work"]); +const emptyFolder = project("p-empty", "Empty folder"); + +test("draws the three sections with their conversations and counts", async () => { + const view = await renderSections({ + channels: [groupChat, directChat, filedChat], + projects: [designFolder, emptyFolder], + }); + + // Every header names itself and counts what is under it. Projects counts what is filed in all of + // its folders, not how many folders there are. + expect( + within(header(view.container, "Group chats")).getByText("1"), + ).toBeTruthy(); + expect( + within(header(view.container, "Direct chats")).getByText("1"), + ).toBeTruthy(); + expect( + within(header(view.container, "Projects")).getByText("1"), + ).toBeTruthy(); + + // Each conversation is drawn once, and the filed one only inside its folder. + expect(within(view.container).getAllByText("Team room")).toHaveLength(1); + expect(within(view.container).getAllByText("My assistant")).toHaveLength(1); + expect(within(view.container).getAllByText("Website work")).toHaveLength(1); + + const groupSection = bodyOf(view.container, "sidebar-section-group"); + expect(within(groupSection).getByText("Team room")).toBeTruthy(); + expect(within(groupSection).queryByText("Website work")).toBeNull(); + + const projectsSection = bodyOf(view.container, "sidebar-section-projects"); + expect(within(projectsSection).getByText("Website redesign")).toBeTruthy(); + expect(within(projectsSection).getByText("Empty folder")).toBeTruthy(); + expect(within(projectsSection).getByText("Website work")).toBeTruthy(); +}); + +test("a header click asks for the section it belongs to, and nothing else", async () => { + const onToggle = mock((_id: keyof SidebarSections) => {}); + const view = await renderSections({ + channels: [groupChat, directChat], + onToggle, + }); + + await userEvent.click(header(view.container, "Group chats")); + expect(onToggle.mock.calls).toEqual([["group"]]); + + await userEvent.click(header(view.container, "Direct chats")); + expect(onToggle.mock.calls).toEqual([["group"], ["direct"]]); + + await userEvent.click(header(view.container, "Projects")); + expect(onToggle.mock.calls).toEqual([["group"], ["direct"], ["projects"]]); +}); + +test("a folded section keeps its header, says it is folded, and hides its rows", async () => { + const view = await renderSections({ + channels: [groupChat, directChat], + collapsed: { group: true, direct: false, projects: false }, + }); + + const group = header(view.container, "Group chats"); + expect(group.getAttribute("aria-expanded")).toBe("false"); + expect(bodyOf(view.container, "sidebar-section-group").hidden).toBe(true); + + // The section below it is untouched: a fold is one flag, not a mode. + const direct = header(view.container, "Direct chats"); + expect(direct.getAttribute("aria-expanded")).toBe("true"); + expect(bodyOf(view.container, "sidebar-section-direct").hidden).toBe(false); +}); + +test("aria-controls names the list the header folds", async () => { + const view = await renderSections({ channels: [groupChat, directChat] }); + + for (const [title, id] of [ + ["Group chats", "sidebar-section-group"], + ["Direct chats", "sidebar-section-direct"], + ["Projects", "sidebar-section-projects"], + ] as const) { + const button = header(view.container, title); + expect(button.getAttribute("aria-controls")).toBe(id); + expect(view.container.querySelector(`#${CSS.escape(id)}`)).not.toBeNull(); + } +}); + +test("a search opens the sections it matched, without writing the fold away", async () => { + // A conversation in a folder too: Projects with nothing filed and a search running is dropped + // rather than drawn empty, which the next test covers. + const view = await renderSections({ + channels: [groupChat, directChat, filedChat], + projects: [designFolder], + collapsed: { group: true, direct: true, projects: true }, + searching: true, + }); + + // Every section is drawn open while the box holds something, so a match found inside a folded + // section is shown rather than only counted. + for (const title of ["Group chats", "Direct chats", "Projects"]) { + expect(header(view.container, title).getAttribute("aria-expanded")).toBe( + "true", + ); + } + expect(bodyOf(view.container, "sidebar-section-group").hidden).toBe(false); + + cleanup(); + + // The same flags with the box empty are exactly what the person folded: search is an overlay on + // the stored flags, not an edit of them. + const quiet = await renderSections({ + channels: [groupChat, directChat, filedChat], + projects: [designFolder], + collapsed: { group: true, direct: true, projects: true }, + }); + expect( + header(quiet.container, "Group chats").getAttribute("aria-expanded"), + ).toBe("false"); +}); + +test("an empty section says so when idle, and is dropped while searching", async () => { + const idle = await renderSections({ channels: [directChat] }); + expect( + within(bodyOf(idle.container, "sidebar-section-group")).getByText( + "No group chats yet", + ), + ).toBeTruthy(); + expect(idle.container.querySelector("#sidebar-project-hint")).toBeNull(); + + cleanup(); + + // "No group chats yet" beside a box holding somebody's name would be the wrong sentence about the + // wrong thing, so an empty section leaves the sidebar while a search is running. + const searching = await renderSections({ + channels: [directChat], + searching: true, + }); + expect(within(searching.container).queryByText("Group chats")).toBeNull(); + expect(within(searching.container).getByText("Direct chats")).toBeTruthy(); +}); + +test("Projects holds folders, counts what is filed in them, and says how to start one", async () => { + const empty = await renderSections({ channels: [directChat] }); + const projectsBody = bodyOf(empty.container, "sidebar-section-projects"); + expect(within(projectsBody).getByText("No projects yet")).toBeTruthy(); + expect( + within(projectsBody).getByText( + "Folders of your own: make one with +, then file a conversation into it from its menu.", + ), + ).toBeTruthy(); +}); + +test("a folder folds on its own, and the section above it stays open", async () => { + const onToggle = mock((_id: keyof SidebarSections) => {}); + const view = await renderSections({ + channels: [filedChat], + projects: [designFolder], + onToggle, + }); + + const folderButton = within( + bodyOf(view.container, "sidebar-section-projects"), + ) + .getByText("Website redesign") + .closest("button") as HTMLButtonElement | null; + if (!folderButton) throw new Error("the folder has no header button"); + + expect(folderButton.getAttribute("aria-expanded")).toBe("true"); + const folderBodyId = folderButton.getAttribute("aria-controls"); + if (!folderBodyId) throw new Error("the folder header names no body"); + expect( + view.container + .querySelector(`#${CSS.escape(folderBodyId)}`) + ?.hasAttribute("hidden"), + ).toBe(false); + + await userEvent.click(folderButton); + + // Folded where it was clicked; the section is not asked to fold and the Projects header keeps its + // own answer. A folder's fold is remembered by nothing — it is a detail of this moment. + expect(folderButton.getAttribute("aria-expanded")).toBe("false"); + expect( + view.container + .querySelector(`#${CSS.escape(folderBodyId)}`) + ?.hasAttribute("hidden"), + ).toBe(true); + expect(onToggle.mock.calls).toEqual([]); + expect(header(view.container, "Projects").getAttribute("aria-expanded")).toBe( + "true", + ); +}); + +test("a folder's rows are reachable while it is open, and gone from the list when folded", async () => { + const view = await renderSections({ + channels: [filedChat], + projects: [designFolder], + }); + + const projectsBody = bodyOf(view.container, "sidebar-section-projects"); + const folderButton = within(projectsBody) + .getByText("Website redesign") + .closest("button") as HTMLButtonElement; + const folderBody = view.container.querySelector( + `#${CSS.escape(folderButton.getAttribute("aria-controls") ?? "")}`, + ); + if (!folderBody) throw new Error("the folder body is missing"); + + expect(within(folderBody).getByText("Website work")).toBeTruthy(); + + await userEvent.click(folderButton); + expect(folderBody.hidden).toBe(true); +}); + +/* + * The menu. A conversation's "Move to project ▸" is the only way a conversation is filed, so what it + * offers and what it does when a row is picked is the feature's whole client half. + */ + +/** Opens a conversation's context menu and returns what it drew. */ +async function openRowMenu(container: HTMLElement, rowName: string) { + // The menu trigger wraps the row's link, so the right-click lands on the whole row. + const trigger = within(container).getByText(rowName).closest("a"); + if (!trigger) throw new Error(`no row for "${rowName}"`); + fireEvent.contextMenu(trigger); + return await waitFor(() => { + const menu = document.querySelector( + '[data-slot="context-menu-content"]', + ); + if (!menu) throw new Error("the context menu did not open"); + return menu; + }); +} + +test("move-to-project offers every folder but the one it is already in", async () => { + const view = await renderSections({ + channels: [filedChat], + projects: [designFolder, emptyFolder], + }); + + const menu = await openRowMenu(view.container, "Website work"); + expect(within(menu).getByText("Move to project")).toBeTruthy(); + + await userEvent.click(within(menu).getByText("Move to project")); + + const offers = await waitFor(() => { + const rows = [ + ...document.querySelectorAll('[data-slot="context-menu-item"]'), + ] + .map((row) => row.textContent?.trim()) + .filter(Boolean) as string[]; + if (!rows.some((row) => row.includes("Empty folder"))) + throw new Error("the submenu did not open"); + return rows; + }); + + // The folder it is already filed in is not an action: it is left out rather than drawn disabled. + expect(offers.some((row) => row?.includes("Website redesign"))).toBe(false); + expect(offers.some((row) => row?.includes("Empty folder"))).toBe(true); + expect(offers.some((row) => row?.includes("New project"))).toBe(true); + // And the way back out, because this conversation is filed somewhere. + expect(offers.some((row) => row?.includes("Remove from project"))).toBe(true); +}); + +test("picking a folder files the conversation it was opened on", async () => { + const seen = servingMove(); + const view = await renderSections({ + channels: [directChat], + projects: [emptyFolder], + }); + + const menu = await openRowMenu(view.container, "My assistant"); + await userEvent.click(within(menu).getByText("Move to project")); + + const row = await waitFor(() => { + const found = [ + ...document.querySelectorAll('[data-slot="context-menu-item"]'), + ].find((item) => item.textContent?.includes("Empty folder")); + if (!found) throw new Error("the folder row did not open"); + return found as HTMLElement; + }); + await userEvent.click(row); + + await waitFor(() => expect(seen).toHaveLength(1)); + expect(seen[0]?.url).toBe("/api/channels/my-assistant/project"); + expect(seen[0]?.init?.method).toBe("PATCH"); + expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ + projectId: "p-empty", + }); +}); + +test("an unfiled conversation offers no way out of a project", async () => { + const view = await renderSections({ + channels: [directChat], + projects: [emptyFolder], + }); + + const menu = await openRowMenu(view.container, "My assistant"); + expect(within(menu).queryByText("Remove from project")).toBeNull(); + expect(within(menu).getByText("Move to project")).toBeTruthy(); +}); + +test("a refused move is said on the row that asked", async () => { + const seenRequests = servingMove(404, { + error: "That project is not yours.", + }); + const view = await renderSections({ + channels: [directChat], + projects: [emptyFolder], + }); + + const menu = await openRowMenu(view.container, "My assistant"); + await userEvent.click(within(menu).getByText("Move to project")); + const row = await waitFor(() => { + const found = [ + ...document.querySelectorAll('[data-slot="context-menu-item"]'), + ].find((item) => item.textContent?.includes("Empty folder")); + if (!found) throw new Error("the folder row did not open"); + return found as HTMLElement; + }); + await userEvent.click(row); + + // The menu has closed by then, so the sentence lands on the row: a conversation that quietly + // stayed where it was reads as a click that did nothing. + const alert = await waitFor(() => { + const found = view.container.querySelector('[role="alert"]'); + if (!found) throw new Error("the row said nothing about the refused move"); + return found; + }); + expect(alert.textContent).toBe("That project is not yours."); + // And the refusal was the server's, not a click that never left the page. + expect( + seenRequests.map((request) => [ + request.url, + request.init?.method, + String(request.init?.body), + ]), + ).toEqual([ + [ + "/api/channels/my-assistant/project", + "PATCH", + JSON.stringify({ projectId: "p-empty" }), + ], + ]); +}); + +/* + * The drag. A row carries its channel; a folder's header takes the drop. Both halves are drawn + * here, so what a rendered tree can say is whether the payload left the row and reached the folder + * the person was looking at. + */ + +/** The dataTransfer of a drag these tests drive, because jsdom has none of its own. */ +function dragPayload() { + const types: string[] = []; + const values = new Map(); + return { + get types() { + return [...types]; + }, + setData(type: string, value: string) { + if (!types.includes(type)) types.push(type); + values.set(type, value); + }, + getData: (type: string) => values.get(type) ?? "", + dropEffect: "", + effectAllowed: "", + }; +} + +/** The link of one conversation row, which is what was made draggable. */ +function rowLink(container: HTMLElement, rowName: string) { + const link = within(container).getByText(rowName).closest("a"); + if (!link) throw new Error(`no row for "${rowName}"`); + return link; +} + +test("dropping a dragged row on a folder files it in that folder", async () => { + const seen = servingMove(); + const view = await renderSections({ + channels: [directChat, filedChat], + projects: [designFolder, emptyFolder], + }); + + const row = rowLink(view.container, "My assistant"); + expect(row.getAttribute("draggable")).toBe("true"); + const payload = dragPayload(); + fireEvent.dragStart(row, { dataTransfer: payload }); + const folder = header(view.container, "Website redesign"); + fireEvent.dragOver(folder, { dataTransfer: payload }); + fireEvent.drop(folder, { dataTransfer: payload }); + + await waitFor(() => expect(seen).toHaveLength(1)); + expect(seen[0]?.url).toBe("/api/channels/my-assistant/project"); + expect(seen[0]?.init?.method).toBe("PATCH"); + expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ + projectId: "p-website", + }); +}); + +test("a refused drop is said on the folder it was dropped on", async () => { + servingMove(404, { error: "That project is not yours." }); + const view = await renderSections({ + channels: [directChat], + projects: [designFolder], + }); + + const payload = dragPayload(); + fireEvent.dragStart(rowLink(view.container, "My assistant"), { + dataTransfer: payload, + }); + const folder = header(view.container, "Website redesign"); + fireEvent.dragOver(folder, { dataTransfer: payload }); + fireEvent.drop(folder, { dataTransfer: payload }); + + const alert = await waitFor(() => { + const found = view.container.querySelector('[role="alert"]'); + if (!found) + throw new Error("the folder said nothing about the refused drop"); + return found; + }); + expect(alert.textContent).toBe("That project is not yours."); +}); diff --git a/app/tests/skins-account.test.tsx b/app/tests/skins-account.test.tsx index 9269c20843..e4fb46f8e7 100644 --- a/app/tests/skins-account.test.tsx +++ b/app/tests/skins-account.test.tsx @@ -38,6 +38,7 @@ import { useSkinId, } from "@/skins/store"; import { settleReactWork } from "./settle-react-work"; +import { makeUserPreferences } from "./user-preferences.fixture"; /* * The skin is saved on the person's account. These drive it the way a person does: the picker on @@ -71,13 +72,7 @@ const alice = { }; function preferences(skin: UserPreferences["skin"]): UserPreferences { - return { - messageListEmphasis: "thread", - selfHostBannerDismissed: true, - narration: null, - skin, - proactive: "on", - }; + return makeUserPreferences({ skin }); } const originalFetch = globalThis.fetch; diff --git a/app/tests/user-preferences.fixture.ts b/app/tests/user-preferences.fixture.ts new file mode 100644 index 0000000000..bc2cefa87c --- /dev/null +++ b/app/tests/user-preferences.fixture.ts @@ -0,0 +1,18 @@ +import { + DEFAULT_USER_PREFERENCES, + type UserPreferences, +} from "@openbot/shared/user-preferences"; + +/** + * A complete set of account preferences, with only the fields a test cares about changed. + * + * The server always answers with every field (`withDefaults` in `server/src/user-preferences.ts`), + * so a preferences value put in the query cache has to be complete as well. Spelling it out in each + * test is what let a newly added field go missing from a fixture and fail the typecheck instead of + * the test; overriding the defaults keeps the fixtures honest without repeating them. + */ +export function makeUserPreferences( + overrides: Partial = {}, +): UserPreferences { + return { ...DEFAULT_USER_PREFERENCES, ...overrides }; +} diff --git a/app/tests/user-preferences.test.tsx b/app/tests/user-preferences.test.tsx index 88ba8f2128..4296f671ac 100644 --- a/app/tests/user-preferences.test.tsx +++ b/app/tests/user-preferences.test.tsx @@ -16,6 +16,7 @@ import { saveUserPreferencesMutationOptions, userPreferencesQueryOptions, } from "@/lib/settings/message-list"; +import { makeUserPreferences } from "./user-preferences.fixture"; const originalFetch = globalThis.fetch; const clients: QueryClient[] = []; @@ -96,13 +97,13 @@ test("successful writes update only the current account cache; failures preserve const { client, view } = setup(); await view.findByText("General Assistant"); const bobKey = userPreferencesQueryOptions("bob").queryKey; - client.setQueryData(bobKey, { - messageListEmphasis: "agent", - selfHostBannerDismissed: false, - narration: null, - skin: "opendots", - proactive: "on", - }); + client.setQueryData( + bobKey, + makeUserPreferences({ + messageListEmphasis: "agent", + skin: "opendots", + }), + ); const mutation = client .getMutationCache() .build(client, saveUserPreferencesMutationOptions(client, "alice")); @@ -114,6 +115,9 @@ test("successful writes update only the current account cache; failures preserve narration: null, skin: "openbot", proactive: "on", + // The server answers with every field, so the mock has to as well: whatever it leaves out + // is what the cache would be missing after the write. + sidebarSections: { group: false, direct: false, projects: false }, }, }); await mutation.execute({ messageListEmphasis: "thread" }); @@ -125,13 +129,12 @@ test("successful writes update only the current account cache; failures preserve method: "PATCH", body: JSON.stringify({ messageListEmphasis: "thread" }), }); - expect(client.getQueryData(bobKey)).toEqual({ - messageListEmphasis: "agent", - selfHostBannerDismissed: false, - narration: null, - skin: "opendots", - proactive: "on", - }); + expect(client.getQueryData(bobKey)).toEqual( + makeUserPreferences({ + messageListEmphasis: "agent", + skin: "opendots", + }), + ); response = async () => Response.json({ error: "Could not save" }, { status: 500 }); await expect( @@ -141,11 +144,5 @@ test("successful writes update only the current account cache; failures preserve client.getQueryData( userPreferencesQueryOptions("alice").queryKey, ), - ).toEqual({ - messageListEmphasis: "thread", - selfHostBannerDismissed: false, - narration: null, - skin: "openbot", - proactive: "on", - }); + ).toEqual(makeUserPreferences()); }); diff --git a/docs/README.md b/docs/README.md index a2d3889cca..f477c827c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ Start with the root [README](../README.md), then use these references: - [Configuration](configuration.md): environment variables and tenant package YAML. - [Personal edition](personal-edition.md): OpenBot for one person, with no sign-in and no Admin. - [Development](development.md): local setup, migrations, ports, and quality checks. -- [Coworkers](coworkers.md): durable Bot profiles, channels, visibility, deletion, and external AG-UI registration. +- [Coworkers](coworkers.md): durable Bot profiles, channels, visibility, deletion, sidebar project folders, and external AG-UI registration. - [Approvals](approvals.md): which actions a Bot asks before, the order that decides it, and what an admin can close. - [Routines](routines.md): standing instructions a Bot runs on a schedule, the worker that fires them, and who they run as. - [Automatic Learning](automatic-learning.md): which Bots contribute conversations to a Learning container, and receive its published skills. diff --git a/docs/coworkers.md b/docs/coworkers.md index e4d983d965..830b3e6698 100644 --- a/docs/coworkers.md +++ b/docs/coworkers.md @@ -74,6 +74,24 @@ Starting a channel creates a new conversation and Intelligence thread. Two chann Each channel routes through a channel-local proxy agent id, pinned to that channel's thread id, then forwards to the coworker runtime id. +## Grouping conversations into projects + +The conversation sidebar draws three collapsible sections in a fixed order: **Group chats**, **Direct chats**, then **Projects**. A conversation is drawn once. One that is filed in one of the reader's projects appears under that project and nowhere else; everything else is a group chat when it has more than one person in it (`memberCount` above one, the reader included) or more than one Bot, and a direct chat otherwise. Both answers come from fields the roster already carries, so classifying the sidebar costs no request per row. + +A project is one person's folder, not a property of the conversation. Filing is recorded in `channel_projects` — `(project_id, channel_id, owner_user_id)`, unique per channel and owner, so the same channel can sit in one folder for one reader and in no folder for everybody else. The alternative, a `project_id` column on `channels`, would force a single folder on everyone who can see the channel, which is why it was not used. For the same reason nothing about a project grants access: every channel route still resolves the caller in `channel_memberships`, and a project endpoint names only channels the caller is a member of — ids, never titles, so a folder cannot become a way to read a conversation's name. + +| Route | What it does | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `GET /api/projects` | The caller's folders with the ids of the conversations filed in them, in the caller's own order. | +| `POST /api/projects` | Makes a folder for the caller. | +| `PATCH /api/projects/:id` | Renames the caller's folder. Anybody else's is `404`, not `403`, so the answer does not confirm it exists. | +| `DELETE /api/projects/:id` | Drops the folder. Its conversations are untouched and return to Group or Direct. | +| `PATCH /api/channels/:id/project`| Files a conversation into one of the caller's folders, or takes it out (`projectId: null`). Needs channel membership. | + +Deleting a folder deletes filing rows through the foreign key; the conversations and their memberships are not touched, so a deleted folder leaves nothing for anybody to clean up and nothing for a member to lose. + +Which sections a person keeps folded is saved on their account (`sidebarSections` in their user preferences), not in the browser's storage, so a sidebar folded on a laptop is folded on the desktop too. Typing in the sidebar search opens every section for as long as the box holds text — a match inside a folded section is a result the person would otherwise be told about and not shown — and the fold they chose comes back when the box is cleared, because the search is drawn over the saved state rather than written into it. + ## Deleting and hiding Deleting is soft. The coworker stops running, but existing channels remain readable for their members and restore as tombstones. diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index fc040d84d5..0aa974b354 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ "./src/db/schema/memory.ts", "./src/db/schema/defaults.ts", "./src/db/schema/proactive.ts", + "./src/db/schema/projects.ts", "./src/db/schema/tasks.ts", "./src/db/schema/demonstrations.ts", "./src/db/schema/delivery.ts", diff --git a/server/drizzle/0089_exotic_ultragirl.sql b/server/drizzle/0089_exotic_ultragirl.sql new file mode 100644 index 0000000000..365f43f3e6 --- /dev/null +++ b/server/drizzle/0089_exotic_ultragirl.sql @@ -0,0 +1,24 @@ +CREATE TABLE "channel_projects" ( + "project_id" text NOT NULL, + "channel_id" text NOT NULL, + "owner_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "channel_projects_project_id_channel_id_pk" PRIMARY KEY("project_id","channel_id") +); +--> statement-breakpoint +CREATE TABLE "projects" ( + "id" text PRIMARY KEY NOT NULL, + "owner_user_id" text NOT NULL, + "name" text NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "channel_projects" ADD CONSTRAINT "channel_projects_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "channel_projects" ADD CONSTRAINT "channel_projects_channel_id_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."channels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "channel_projects" ADD CONSTRAINT "channel_projects_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "channel_projects_one_folder_per_person_idx" ON "channel_projects" USING btree ("channel_id","owner_user_id");--> statement-breakpoint +CREATE INDEX "channel_projects_by_owner_channel_idx" ON "channel_projects" USING btree ("owner_user_id","channel_id");--> statement-breakpoint +CREATE INDEX "projects_by_owner_idx" ON "projects" USING btree ("owner_user_id","sort_order","created_at"); \ No newline at end of file diff --git a/server/drizzle/meta/0089_snapshot.json b/server/drizzle/meta/0089_snapshot.json new file mode 100644 index 0000000000..d538796eb8 --- /dev/null +++ b/server/drizzle/meta/0089_snapshot.json @@ -0,0 +1,12180 @@ +{ + "id": "322661db-cf3a-4ae2-9dee-2422ccaaea12", + "prevId": "27d4ee22-eea6-486a-8443-26d94dc464ab", + "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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "opening": { + "name": "opening", + "type": "text", + "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 + }, + "model_selection": { + "name": "model_selection", + "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": { + "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 + }, + "isolated": { + "name": "isolated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_by_title": { + "name": "goal_by_title", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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" + }, + "routines_goal_id_responsibilities_id_fk": { + "name": "routines_goal_id_responsibilities_id_fk", + "tableFrom": "routines", + "tableTo": "responsibilities", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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_accounts": { + "name": "brokered_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "connected_at": { + "name": "connected_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": { + "brokered_accounts_account_idx": { + "name": "brokered_accounts_account_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" + }, + { + "expression": "vendor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_accounts_default_idx": { + "name": "brokered_accounts_default_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_accounts\".\"is_default\"", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_accounts_user_idx": { + "name": "brokered_accounts_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_accounts_provider_check": { + "name": "brokered_accounts_provider_check", + "value": "\"brokered_accounts\".\"provider\" IN ('composio', 'arcade')" + } + }, + "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.brokered_identities": { + "name": "brokered_identities", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "brokered_identities_provider_user_id_pk": { + "name": "brokered_identities_provider_user_id_pk", + "columns": [ + "provider", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_identities_provider_check": { + "name": "brokered_identities_provider_check", + "value": "\"brokered_identities\".\"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 + }, + "entra_scope": { + "name": "entra_scope", + "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 + }, + "listing_revision": { + "name": "listing_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "unclassified": { + "name": "unclassified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "declared_read": { + "name": "declared_read", + "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": false + }, + "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": {} + }, + "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_open_skills": { + "name": "learning_open_skills", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "skill_name": { + "name": "skill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "learning_open_skills_user_id_users_id_fk": { + "name": "learning_open_skills_user_id_users_id_fk", + "tableFrom": "learning_open_skills", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "learning_open_skills_user_id_skill_name_pk": { + "name": "learning_open_skills_user_id_skill_name_pk", + "columns": [ + "user_id", + "skill_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_person_spaces": { + "name": "learning_person_spaces", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "container_id": { + "name": "container_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": { + "learning_person_spaces_user_id_users_id_fk": { + "name": "learning_person_spaces_user_id_users_id_fk", + "tableFrom": "learning_person_spaces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "learning_person_spaces_container_id_unique": { + "name": "learning_person_spaces_container_id_unique", + "nullsNotDistinct": false, + "columns": [ + "container_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "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_skill_approvals": { + "name": "learning_skill_approvals", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "skill_name": { + "name": "skill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sharing": { + "name": "sharing", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "learning_skill_approvals_user_id_users_id_fk": { + "name": "learning_skill_approvals_user_id_users_id_fk", + "tableFrom": "learning_skill_approvals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "learning_skill_approvals_user_id_skill_name_pk": { + "name": "learning_skill_approvals_user_id_skill_name_pk", + "columns": [ + "user_id", + "skill_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "learning_skill_approvals_approved_by_check": { + "name": "learning_skill_approvals_approved_by_check", + "value": "\"learning_skill_approvals\".\"approved_by\" IN ('person', 'admin')" + }, + "learning_skill_approvals_sharing_check": { + "name": "learning_skill_approvals_sharing_check", + "value": "\"learning_skill_approvals\".\"sharing\" IN ('none', 'requested', 'approved', 'declined')" + } + }, + "isRLSEnabled": false + }, + "public.learning_skill_sharing": { + "name": "learning_skill_sharing", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'closed'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "learning_skill_sharing_open_idx": { + "name": "learning_skill_sharing_open_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"learning_skill_sharing\".\"mode\" <> 'closed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "learning_skill_sharing_user_id_users_id_fk": { + "name": "learning_skill_sharing_user_id_users_id_fk", + "tableFrom": "learning_skill_sharing", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "learning_skill_sharing_mode_check": { + "name": "learning_skill_sharing_mode_check", + "value": "\"learning_skill_sharing\".\"mode\" IN ('closed', 'open', 'choose')" + } + }, + "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" + }, + "recurring": { + "name": "recurring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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_conversation_grants": { + "name": "approval_conversation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_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 + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "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": { + "approval_conversation_grants_thread_idx": { + "name": "approval_conversation_grants_thread_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_conversation_grants_owner_user_id_users_id_fk": { + "name": "approval_conversation_grants_owner_user_id_users_id_fk", + "tableFrom": "approval_conversation_grants", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_conversation_grants_approval_id_approval_requests_id_fk": { + "name": "approval_conversation_grants_approval_id_approval_requests_id_fk", + "tableFrom": "approval_conversation_grants", + "tableTo": "approval_requests", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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_conversation', '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'" + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "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": { + "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'" + }, + "action_kinds": { + "name": "action_kinds", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "reviewer_guidance": { + "name": "reviewer_guidance", + "type": "text", + "primaryKey": false, + "notNull": 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": { + "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_per_bot_idx": { + "name": "personal_memories_formed_digest_per_bot_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_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_people": { + "name": "proactive_people", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_pinged_at": { + "name": "last_pinged_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": { + "proactive_people_owner_user_id_users_id_fk": { + "name": "proactive_people_owner_user_id_users_id_fk", + "tableFrom": "proactive_people", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proactive_pings": { + "name": "proactive_pings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_count": { + "name": "item_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "push_body": { + "name": "push_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "offered_at": { + "name": "offered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "proactive_pings_owner_created_idx": { + "name": "proactive_pings_owner_created_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": {} + }, + "proactive_pings_unoffered_idx": { + "name": "proactive_pings_unoffered_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"proactive_pings\".\"offered_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_pings_owner_user_id_users_id_fk": { + "name": "proactive_pings_owner_user_id_users_id_fk", + "tableFrom": "proactive_pings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_pings_item_count_check": { + "name": "proactive_pings_item_count_check", + "value": "\"proactive_pings\".\"item_count\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.proactive_runs": { + "name": "proactive_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "setting_id": { + "name": "setting_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": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "items_raised": { + "name": "items_raised", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reads": { + "name": "reads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + } + }, + "indexes": { + "proactive_runs_setting_started_idx": { + "name": "proactive_runs_setting_started_idx", + "columns": [ + { + "expression": "setting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_runs_owner_started_idx": { + "name": "proactive_runs_owner_started_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_runs_agent_started_idx": { + "name": "proactive_runs_agent_started_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_runs_one_running_idx": { + "name": "proactive_runs_one_running_idx", + "columns": [ + { + "expression": "setting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"proactive_runs\".\"outcome\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_runs_setting_id_proactive_settings_id_fk": { + "name": "proactive_runs_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_runs", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_runs_owner_user_id_users_id_fk": { + "name": "proactive_runs_owner_user_id_users_id_fk", + "tableFrom": "proactive_runs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_runs_agent_id_agents_id_fk": { + "name": "proactive_runs_agent_id_agents_id_fk", + "tableFrom": "proactive_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_runs_trigger_check": { + "name": "proactive_runs_trigger_check", + "value": "\"proactive_runs\".\"trigger\" IN ('schedule', 'run_now')" + }, + "proactive_runs_outcome_check": { + "name": "proactive_runs_outcome_check", + "value": "\"proactive_runs\".\"outcome\" IN ('running', 'quiet', 'raised', 'failed', 'skipped')" + }, + "proactive_runs_skip_reason_check": { + "name": "proactive_runs_skip_reason_check", + "value": "(\"proactive_runs\".\"outcome\" = 'skipped' AND \"proactive_runs\".\"skip_reason\" IS NOT NULL AND \"proactive_runs\".\"skip_reason\" IN ('off_admin', 'off_person', 'paused', 'capped', 'unreachable', 'no_sources', 'busy')) OR (\"proactive_runs\".\"outcome\" <> 'skipped' AND \"proactive_runs\".\"skip_reason\" IS NULL)" + }, + "proactive_runs_finished_check": { + "name": "proactive_runs_finished_check", + "value": "(\"proactive_runs\".\"outcome\" = 'running') = (\"proactive_runs\".\"finished_at\" IS NULL)" + } + }, + "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": "''" + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "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": false, + "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'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ping_id": { + "name": "ping_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "proactive_suggestions_open_dedupe_idx": { + "name": "proactive_suggestions_open_dedupe_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"proactive_suggestions\".\"status\" = 'open' AND \"proactive_suggestions\".\"dedupe_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_unpinged_idx": { + "name": "proactive_suggestions_unpinged_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"proactive_suggestions\".\"status\" = 'open' AND \"proactive_suggestions\".\"ping_id\" IS NULL", + "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" + }, + "proactive_suggestions_ping_id_proactive_pings_id_fk": { + "name": "proactive_suggestions_ping_id_proactive_pings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_pings", + "columnsFrom": [ + "ping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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.channel_projects": { + "name": "channel_projects", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_projects_one_folder_per_person_idx": { + "name": "channel_projects_one_folder_per_person_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_projects_by_owner_channel_idx": { + "name": "channel_projects_by_owner_channel_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_projects_project_id_projects_id_fk": { + "name": "channel_projects_project_id_projects_id_fk", + "tableFrom": "channel_projects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_projects_channel_id_channels_id_fk": { + "name": "channel_projects_channel_id_channels_id_fk", + "tableFrom": "channel_projects", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_projects_owner_user_id_users_id_fk": { + "name": "channel_projects_owner_user_id_users_id_fk", + "tableFrom": "channel_projects", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_projects_project_id_channel_id_pk": { + "name": "channel_projects_project_id_channel_id_pk", + "columns": [ + "project_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "projects_by_owner_idx": { + "name": "projects_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_owner_user_id_users_id_fk": { + "name": "projects_owner_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_tasks": { + "name": "bot_tasks", + "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 + }, + "suggestion_id": { + "name": "suggestion_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "why": { + "name": "why", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bot_tasks_owner_created_idx": { + "name": "bot_tasks_owner_created_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": {} + }, + "bot_tasks_channel_idx": { + "name": "bot_tasks_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bot_tasks_suggestion_idx": { + "name": "bot_tasks_suggestion_idx", + "columns": [ + { + "expression": "suggestion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_tasks_owner_user_id_users_id_fk": { + "name": "bot_tasks_owner_user_id_users_id_fk", + "tableFrom": "bot_tasks", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_tasks_agent_id_agents_id_fk": { + "name": "bot_tasks_agent_id_agents_id_fk", + "tableFrom": "bot_tasks", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_tasks_channel_id_channels_id_fk": { + "name": "bot_tasks_channel_id_channels_id_fk", + "tableFrom": "bot_tasks", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_tasks_suggestion_id_proactive_suggestions_id_fk": { + "name": "bot_tasks_suggestion_id_proactive_suggestions_id_fk", + "tableFrom": "bot_tasks", + "tableTo": "proactive_suggestions", + "columnsFrom": [ + "suggestion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "bot_tasks_origin_check": { + "name": "bot_tasks_origin_check", + "value": "\"bot_tasks\".\"origin\" IN ('check_in')" + } + }, + "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 + }, + "public.recorder_connect_codes": { + "name": "recorder_connect_codes", + "schema": "", + "columns": { + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "via": { + "name": "via", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_session": { + "name": "organization_session", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_session_expires_at": { + "name": "organization_session_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "recorder_connect_codes_user_idx": { + "name": "recorder_connect_codes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_connect_codes_user_id_users_id_fk": { + "name": "recorder_connect_codes_user_id_users_id_fk", + "tableFrom": "recorder_connect_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recorder_connections": { + "name": "recorder_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "via": { + "name": "via", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "install_id": { + "name": "install_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_session": { + "name": "organization_session", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_session_expires_at": { + "name": "organization_session_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activity": { + "name": "activity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "activity_at": { + "name": "activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "recorder_connections_user_idx": { + "name": "recorder_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recorder_connections_live_install_idx": { + "name": "recorder_connections_live_install_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "install_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"recorder_connections\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_connections_user_id_users_id_fk": { + "name": "recorder_connections_user_id_users_id_fk", + "tableFrom": "recorder_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "recorder_connections_token_hash_unique": { + "name": "recorder_connections_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recorder_trajectory_bindings": { + "name": "recorder_trajectory_bindings", + "schema": "", + "columns": { + "trajectory_id": { + "name": "trajectory_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recorder_trajectory_bindings_user_idx": { + "name": "recorder_trajectory_bindings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_trajectory_bindings_user_id_users_id_fk": { + "name": "recorder_trajectory_bindings_user_id_users_id_fk", + "tableFrom": "recorder_trajectory_bindings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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 4767de52e2..43f433acb9 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -624,6 +624,13 @@ "when": 1794274563155, "tag": "0088_approval_reviewer_guidance", "breakpoints": true + }, + { + "idx": 89, + "version": "7", + "when": 1794396586531, + "tag": "0089_exotic_ultragirl", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/app.ts b/server/src/app.ts index a22b4c6011..ce0886fe40 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -191,6 +191,11 @@ import { type UserInstructionsStore, } from "./user-instructions"; import type { UserPreferencesStore } from "./user-preferences"; +import { + createChannelProjectRoutes, + createProjectRoutes, +} from "./projects/routes"; +import type { ProjectStore } from "./projects/store"; import { userPreferencesRoutes } from "./user-preferences-routes"; import { createVoiceProvider } from "./voice/provider"; import { createVoiceRoutes } from "./voice/routes"; @@ -568,6 +573,16 @@ export function createApp( /** The GitHub sign-in callback, built by the caller; mounted behind requireUser ahead of /api/identity. */ identityGithubCallback?: Hono<{ Variables: AppVariables }>, profileStore?: ProfileStore, + /** + * The project folders a person files conversations into. + * + * Appended last, like everything above it: these are positional, so inserting one anywhere else + * silently shifts every existing call site's arguments by one. + * + * Absent leaves `/api/projects` unmounted and the sidebar drawing its two membership sections + * only, the same degraded shape every other optional store here takes. + */ + projectStore?: ProjectStore, ) { const app = new Hono<{ Variables: AppVariables }>(); // First, in every auth mode: a page from another website changes nothing here and opens no socket. @@ -1789,6 +1804,19 @@ export function createApp( ); } + if (projectStore) { + app.route("/api/projects", createProjectRoutes(projectStore, requireUser)); + /* + * Filed under the channels prefix, mounted beside the channel routes rather than inside them: + * the move route resolves the caller in `channel_memberships` through the project store, and + * leaving the channel router alone keeps every existing route's permission check untouched. + */ + app.route( + "/api/channels", + createChannelProjectRoutes(projectStore, requireUser), + ); + } + if (attachmentDatabase) { /* * `bodyLimit` sits in front of the upload route itself, not beside the mount below: the handler diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index 67db4dfc75..075aeff94f 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -29,6 +29,7 @@ import { botTasks, channelAgents, channelMemberships, + channelProjects, channels, intelligenceChannelMappings, users, @@ -90,6 +91,22 @@ export type ChannelSummary = AgentChannel & { * "a conversation with this Bot", such as where its check-ins reach the person, leave these out. */ task: boolean; + /** + * How many people are in this channel, the caller included. + * + * Read with the roster so the sidebar can tell a 1:1 conversation from a group without asking + * about every row separately: a channel with more than one person in it is a group chat + * whatever its Bot count. It says nothing about who they are. + */ + memberCount: number; + /** + * The caller's own project folder holding this channel, or null for none. + * + * Per member, like the pin: the person reading the roster may have filed a conversation that + * nobody else has. Read with the page rather than per row, so a sidebar draws its sections from + * one request. + */ + projectId: string | null; }; /** What a client that ran an agent reports back about the message it just saw. */ @@ -711,6 +728,8 @@ export function createChannelStore( lastReadAt: channelMemberships.lastReadAt, canRename: sql`${starterOf(channels.id)} = ${actor.id}`, task: sql`exists (select 1 from ${botTasks} where ${botTasks.channelId} = ${channels.id})`, + memberCount: sql`(select count(*)::int from ${channelMemberships} where ${channelMemberships.channelId} = ${channels.id})`, + projectId: channelProjects.projectId, }) .from(channels) .innerJoin( @@ -732,6 +751,18 @@ export function createChannelStore( agentProfiles, eq(agentProfiles.agentId, channelAgents.agentId), ) + /* + * The caller's own folder, if any. A left join and not a subquery, because a page holds a + * few dozen rows and this reads them in the same statement; the unique index on + * (channel_id, owner_user_id) is what keeps it from multiplying the rows. + */ + .leftJoin( + channelProjects, + and( + eq(channelProjects.channelId, channels.id), + eq(channelProjects.ownerUserId, actor.id), + ), + ) .where( and( inArray( @@ -773,6 +804,8 @@ export function createChannelStore( lastReadAt: row.lastReadAt, canRename: row.canRename, task: row.task, + memberCount: row.memberCount, + projectId: row.projectId, }); } return { channels: [...summaries.values()], nextCursor }; @@ -1701,6 +1734,9 @@ function channelSummaryDto( lastReadAt: channel.lastReadAt?.toISOString() ?? null, canRename: channel.canRename, task: channel.task, + // Read with the roster so the sidebar can draw its sections from one request. + memberCount: channel.memberCount, + projectId: channel.projectId, 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 diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts index 53bbf55983..fcb0e4fda7 100644 --- a/server/src/db/schema/index.ts +++ b/server/src/db/schema/index.ts @@ -17,6 +17,7 @@ export * from "./memory"; export * from "./passwords"; export * from "./plugins"; export * from "./proactive"; +export * from "./projects"; export * from "./recorder"; export * from "./responsibilities"; export * from "./scim"; diff --git a/server/src/db/schema/projects.ts b/server/src/db/schema/projects.ts new file mode 100644 index 0000000000..d98f369387 --- /dev/null +++ b/server/src/db/schema/projects.ts @@ -0,0 +1,100 @@ +/** + * Project folders for the sidebar. + * + * A project is a person's own filing of conversations: it groups channels in their sidebar and + * nothing else. It carries no permission of its own — every channel route still resolves the caller + * in `channel_memberships` — so a project can only ever hold a channel its owner could already + * open, and moving one into a folder is filing rather than sharing. + */ +import { + index, + integer, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { channels, users } from "./core"; + +const createdAt = () => + timestamp("created_at", { withTimezone: true }).notNull().defaultNow(); +const updatedAt = () => + timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(); + +/** + * One person's folder of conversations. + * + * Per person, unlike a channel: two members of the same channel each file it where they like, and a + * project belongs to the person who made it. That is why the filing lives in the join table below + * rather than as a `project_id` column on `channels`, which would have to mean one folder for + * everybody who can see the channel. + */ +export const projects = pgTable( + "projects", + { + id: text("id").primaryKey(), + /** Who made it, and the only person who may rename it, delete it or file channels into it. */ + ownerUserId: text("owner_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + /** Where the folder sits in this person's Projects section. Ties fall back to `created_at`. */ + sortOrder: integer("sort_order").notNull().default(0), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + /** The Projects section reads one person's folders, in their order. */ + index("projects_by_owner_idx").on( + table.ownerUserId, + table.sortOrder, + table.createdAt, + ), + ], +); + +/** + * Which conversation a person has filed in which of their folders. + * + * THREE COLUMNS, NOT TWO, because the fact being stored is per person. `owner_user_id` is the same + * value as `projects.owner_user_id` and exists so the uniqueness rule below can be expressed in one + * index: one folder per conversation per person, while two people may file the same conversation + * into two different folders. Neither a primary key on `(project_id, channel_id)` nor a nullable + * column on `channels` can say that. + * + * Deleting a project deletes these rows and nothing else, so the conversations return to the + * Group/Direct sections intact. + */ +export const channelProjects = pgTable( + "channel_projects", + { + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + channelId: text("channel_id") + .notNull() + .references(() => channels.id, { onDelete: "cascade" }), + /** + * The person whose filing this is. Written from the project's owner, which never changes, and + * kept here rather than joined at read time so a violation of "one folder per person" is a + * constraint rather than a race two requests could both pass. + */ + ownerUserId: text("owner_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + createdAt: createdAt(), + }, + (table) => [ + primaryKey({ columns: [table.projectId, table.channelId] }), + uniqueIndex("channel_projects_one_folder_per_person_idx").on( + table.channelId, + table.ownerUserId, + ), + /** Reading a page of channels joins this by (channel, person) on every roster request. */ + index("channel_projects_by_owner_channel_idx").on( + table.ownerUserId, + table.channelId, + ), + ], +); diff --git a/server/src/index.ts b/server/src/index.ts index 693948e0c9..a3b6ac664e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -115,6 +115,7 @@ import { } from "./channels/group"; import { createModelChoices } from "./channels/model-choice"; import { type ChannelStore, createChannelStore } from "./channels/routes"; +import { createProjectStore } from "./projects/store"; import { createStallGuard } from "./channels/stall-guard"; import { forgetSettledSummaries, @@ -492,6 +493,7 @@ const tenantPackage = await loadTenantPackage(config.tenantPackageDirectory); const deploymentId = config.deploymentId ?? (await deploymentNameOf(database, tenantPackage)); const threadIdentity = createThreadIdentity(deploymentId); + const baseChannelStore = createChannelStore( database, agentProfileStore, @@ -3369,6 +3371,8 @@ const app = createApp( identityStore, identityGithubCallback, profileStore, + // A person's own folders for the sidebar. Owner-scoped, and no permission of its own. + createProjectStore(database), ); startHttpServer({ diff --git a/server/src/projects/routes.ts b/server/src/projects/routes.ts new file mode 100644 index 0000000000..a2d52cd705 --- /dev/null +++ b/server/src/projects/routes.ts @@ -0,0 +1,176 @@ +/** + * Project folders: the HTTP half. + * + * Two routers, because the feature sits under two prefixes: the folders themselves, and the filing + * of one conversation. Keeping them here rather than in `channels/routes.ts` leaves every existing + * channel route resolving its caller in `channel_memberships` exactly as it did — the move route + * does the same check against the same table in `store.moveChannel`. + */ +import type { Context, MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import type { AppVariables } from "../auth/guards"; +import { + MAX_PROJECT_NAME_LENGTH, + ProjectChannelNotFoundError, + ProjectNotFoundError as ProjectMissing, + type ProjectStore, + type ProjectSummary, +} from "./store"; + +type Parsed = { ok: true; value: T } | { ok: false; error: string }; + +/** + * A folder name, from what the browser sent. + * + * Trimmed and collapsed to one line: the name is drawn in a sidebar row and used nowhere else, so + * a newline in it is a rendering bug rather than a feature. 60 is the ceiling because the row + * truncates anyway and a name nobody can read back in full is worse than a refusal. + */ +export function parseProjectName(input: unknown): Parsed<{ name: string }> { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return { ok: false, error: "Project input must be a JSON object." }; + } + const { name } = input as { name?: unknown }; + if (typeof name !== "string") { + return { ok: false, error: "A project name is required." }; + } + const cleaned = name.replace(/\s+/g, " ").trim(); + if (cleaned.length === 0) { + return { ok: false, error: "A project name is required." }; + } + if (cleaned.length > MAX_PROJECT_NAME_LENGTH) { + return { + ok: false, + error: `A project name can be at most ${MAX_PROJECT_NAME_LENGTH} characters.`, + }; + } + return { ok: true, value: { name: cleaned } }; +} + +/** `{ projectId: string | null }`, which is what "move" and "remove from project" both send. */ +export function parseProjectMove( + input: unknown, +): Parsed<{ projectId: string | null }> { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return { ok: false, error: "Move input must be a JSON object." }; + } + const body = input as { projectId?: unknown }; + if (!("projectId" in body)) { + return { + ok: false, + error: + "projectId must be a project id, or null to take the conversation out.", + }; + } + if (body.projectId === null) return { ok: true, value: { projectId: null } }; + if (typeof body.projectId !== "string" || body.projectId.trim() === "") { + return { + ok: false, + error: + "projectId must be a project id, or null to take the conversation out.", + }; + } + return { ok: true, value: { projectId: body.projectId.trim() } }; +} + +/** A folder as the browser sees it: dates as ISO-8601, like every other channel payload. */ +function projectDto(project: ProjectSummary) { + return { + id: project.id, + name: project.name, + sortOrder: project.sortOrder, + createdAt: project.createdAt.toISOString(), + updatedAt: project.updatedAt.toISOString(), + channelIds: project.channelIds, + }; +} + +function mapProjectError(context: Context, error: unknown): Response { + if (error instanceof ProjectMissing) { + return context.json({ error: "Couldn't find that project." }, 404); + } + if (error instanceof ProjectChannelNotFoundError) { + return context.json({ error: "Couldn't find that conversation." }, 404); + } + throw error; +} + +/** `/api/projects`: this person's folders. Owner-scoped from the session, never from the path. */ +export function createProjectRoutes( + store: ProjectStore, + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, +) { + const routes = new Hono<{ Variables: AppVariables }>(); + + routes.get("/", requireUser, async (context) => { + const projects = await store.list(context.var.actor); + return context.json({ projects: projects.map(projectDto) }); + }); + + routes.post("/", requireUser, async (context) => { + const parsed = parseProjectName(await context.req.json().catch(() => null)); + if (!parsed.ok) return context.json({ error: parsed.error }, 400); + const project = await store.create(context.var.actor, parsed.value.name); + return context.json({ project: projectDto(project) }, 201); + }); + + routes.patch("/:projectId", requireUser, async (context) => { + const parsed = parseProjectName(await context.req.json().catch(() => null)); + if (!parsed.ok) return context.json({ error: parsed.error }, 400); + try { + const project = await store.rename( + context.var.actor, + context.req.param("projectId"), + parsed.value.name, + ); + return context.json({ project: projectDto(project) }); + } catch (error) { + return mapProjectError(context, error); + } + }); + + routes.delete("/:projectId", requireUser, async (context) => { + try { + await store.remove(context.var.actor, context.req.param("projectId")); + return context.body(null, 204); + } catch (error) { + return mapProjectError(context, error); + } + }); + + return routes; +} + +/** + * `/api/channels/:channelId/project`: file one conversation, or take it back out. + * + * Returns 204 because nothing about the conversation changes except where its owner's sidebar draws + * it; the browser refetches the roster and the folders, both of which it already holds. + */ +export function createChannelProjectRoutes( + store: ProjectStore, + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, +) { + const routes = new Hono<{ Variables: AppVariables }>(); + + routes.patch("/:channelId/project", requireUser, async (context) => { + const parsed = parseProjectMove(await context.req.json().catch(() => null)); + if (!parsed.ok) return context.json({ error: parsed.error }, 400); + const channelId = context.req.param("channelId"); + if (!channelId.trim()) { + return context.json({ error: "A channel id is required." }, 400); + } + try { + await store.moveChannel( + context.var.actor, + channelId, + parsed.value.projectId, + ); + return context.body(null, 204); + } catch (error) { + return mapProjectError(context, error); + } + }); + + return routes; +} diff --git a/server/src/projects/store.ts b/server/src/projects/store.ts new file mode 100644 index 0000000000..72049da7c7 --- /dev/null +++ b/server/src/projects/store.ts @@ -0,0 +1,280 @@ +/** + * Project folders: the storage half. + * + * A project is one person's filing of their own conversations. Nothing here grants access to a + * channel: `moveChannel` refuses a channel the caller is not in, and every channel route keeps + * resolving the caller in `channel_memberships` as before. A folder only decides where a row is + * drawn in that person's sidebar. + */ +import { and, asc, eq, isNull, sql } from "drizzle-orm"; +import type { AgentActor } from "../agents/profile-types"; +import type { Database } from "../db/client"; +import { + channelMemberships, + channelProjects, + channels, + projects, +} from "../db/schema"; + +/** A folder as the sidebar needs it: its name, its order, and which conversations it holds. */ +export type ProjectSummary = { + id: string; + name: string; + sortOrder: number; + createdAt: Date; + updatedAt: Date; + /** + * The conversations filed here, by id only. + * + * Ids rather than names, because this is the one read that could otherwise carry a channel's + * title to somebody who cannot open it. A folder holds only its owner's channels, and the query + * below filters to channels that person is still a member of, so nothing leaks — and with ids + * alone there is nothing in the payload to leak even if that filter were ever wrong. + */ + channelIds: string[]; +}; + +/** No such folder, or one belonging to somebody else. The caller is told the same thing either way. */ +export class ProjectNotFoundError extends Error { + constructor(id: string) { + super(`Project ${id} was not found.`); + this.name = "ProjectNotFoundError"; + } +} + +/** + * The channel is not one the caller can open. + * + * A deleted channel answers the same way a channel somebody else owns does, which is the rule the + * channel routes already follow: a member who is not one is not told which of the two it was. + */ +export class ProjectChannelNotFoundError extends Error { + constructor(id: string) { + super(`Channel ${id} was not found.`); + this.name = "ProjectChannelNotFoundError"; + } +} + +/** A name a folder can carry: trimmed, not empty, and short enough for a sidebar row. */ +export const MAX_PROJECT_NAME_LENGTH = 60; + +export type ProjectStore = { + /** This person's folders, in their order, each with the conversations it holds. */ + list(actor: AgentActor): Promise; + create(actor: AgentActor, name: string): Promise; + /** Renames a folder. Throws `ProjectNotFoundError` for anybody but its owner. */ + rename( + actor: AgentActor, + projectId: string, + name: string, + ): Promise; + /** + * Deletes a folder and its filing, leaving every conversation in it untouched: the rows in + * `channel_projects` go with the project, and the channels return to Group/Direct on the next + * read. Throws `ProjectNotFoundError` for anybody but its owner. + */ + remove(actor: AgentActor, projectId: string): Promise; + /** + * Files a conversation into a folder of the caller's, or takes it out when `projectId` is null. + * + * Refuses with `ProjectChannelNotFoundError` when the caller is not a member of the channel, and + * with `ProjectNotFoundError` when the folder is not theirs — so a member cannot be filed into + * somebody else's project, and a stranger cannot learn a channel exists by trying to move it. + */ + moveChannel( + actor: AgentActor, + channelId: string, + projectId: string | null, + ): Promise; +}; + +export function createProjectStore(database: Database): ProjectStore { + /** The folder, if this person owns it. Used before every write. */ + const owned = async (actor: AgentActor, projectId: string) => { + const [row] = await database + .select({ id: projects.id }) + .from(projects) + .where( + and(eq(projects.id, projectId), eq(projects.ownerUserId, actor.id)), + ) + .limit(1); + if (!row) throw new ProjectNotFoundError(projectId); + return row; + }; + + /** + * The conversations this person has filed, grouped by folder. + * + * Read with the membership join rather than from the filing rows alone: a folder must never name + * a conversation its owner cannot open, whether the channel was deleted or they are no longer in + * it, and this is the same join every channel route resolves its caller with. + */ + const filedByProject = async (userId: string) => { + const filed = await database + .select({ + projectId: channelProjects.projectId, + channelId: channelProjects.channelId, + }) + .from(channelProjects) + .innerJoin(channels, eq(channels.id, channelProjects.channelId)) + .innerJoin( + channelMemberships, + and( + eq(channelMemberships.channelId, channelProjects.channelId), + eq(channelMemberships.userId, userId), + ), + ) + /* A deleted conversation is not there to be listed in a folder either. */ + .where( + and( + eq(channelProjects.ownerUserId, userId), + isNull(channels.deletedAt), + ), + ); + + const byProject = new Map(); + for (const row of filed) { + const ids = byProject.get(row.projectId); + if (ids) ids.push(row.channelId); + else byProject.set(row.projectId, [row.channelId]); + } + return byProject; + }; + + return { + async list(actor) { + const rows = await database + .select({ + id: projects.id, + name: projects.name, + sortOrder: projects.sortOrder, + createdAt: projects.createdAt, + updatedAt: projects.updatedAt, + }) + .from(projects) + .where(eq(projects.ownerUserId, actor.id)) + .orderBy( + asc(projects.sortOrder), + asc(projects.createdAt), + asc(projects.id), + ); + + const byProject = await filedByProject(actor.id); + + return rows.map((row) => ({ + ...row, + channelIds: byProject.get(row.id) ?? [], + })); + }, + + async create(actor, name) { + /* + * The order is read then written rather than derived in one statement, which two folders + * created at the same instant could both pass. The tie is not a correctness problem — the + * listing breaks it on `created_at` and then `id` — so the extra round trip buys a stable + * order without a lock on every creation. + */ + const [next] = await database + .select({ + sortOrder: sql`coalesce(max(${projects.sortOrder}) + 1, 0)::int`, + }) + .from(projects) + .where(eq(projects.ownerUserId, actor.id)); + const [created] = await database + .insert(projects) + .values({ + id: `project_${crypto.randomUUID()}`, + ownerUserId: actor.id, + name, + sortOrder: next?.sortOrder ?? 0, + }) + .returning({ + id: projects.id, + name: projects.name, + sortOrder: projects.sortOrder, + createdAt: projects.createdAt, + updatedAt: projects.updatedAt, + }); + if (!created) throw new Error("The project could not be created."); + return { ...created, channelIds: [] }; + }, + + async rename(actor, projectId, name) { + const [renamed] = await database + .update(projects) + .set({ name, updatedAt: new Date() }) + .where( + and(eq(projects.id, projectId), eq(projects.ownerUserId, actor.id)), + ) + .returning({ + id: projects.id, + name: projects.name, + sortOrder: projects.sortOrder, + createdAt: projects.createdAt, + updatedAt: projects.updatedAt, + }); + if (!renamed) throw new ProjectNotFoundError(projectId); + // A rename leaves the filing alone, so the folder is read back rather than guessed at. + const filed = await filedByProject(actor.id); + return { ...renamed, channelIds: filed.get(projectId) ?? [] }; + }, + + async remove(actor, projectId) { + const [deleted] = await database + .delete(projects) + .where( + and(eq(projects.id, projectId), eq(projects.ownerUserId, actor.id)), + ) + .returning({ id: projects.id }); + /* + * The filing rows go with the project by cascade. The channels are untouched: no channel + * row, membership or transcript is referenced by the row that was deleted, which is what + * makes "deleting a folder returns its conversations" true rather than a cleanup step. + */ + if (!deleted) throw new ProjectNotFoundError(projectId); + }, + + async moveChannel(actor, channelId, projectId) { + /* Membership first, in the same place every channel route resolves it. */ + const [membership] = await database + .select({ channelId: channelMemberships.channelId }) + .from(channelMemberships) + .innerJoin(channels, eq(channels.id, channelMemberships.channelId)) + .where( + and( + eq(channelMemberships.channelId, channelId), + eq(channelMemberships.userId, actor.id), + isNull(channels.deletedAt), + ), + ) + .limit(1); + if (!membership) throw new ProjectChannelNotFoundError(channelId); + + if (projectId === null) { + // Out of whatever folder this person had it in. Nothing else changes. + await database + .delete(channelProjects) + .where( + and( + eq(channelProjects.channelId, channelId), + eq(channelProjects.ownerUserId, actor.id), + ), + ); + return; + } + + const project = await owned(actor, projectId); + /* + * Upsert on the per-person key, so filing a conversation that is already in a folder moves + * it rather than failing on the primary key of the row it is leaving. + */ + await database + .insert(channelProjects) + .values({ projectId: project.id, channelId, ownerUserId: actor.id }) + .onConflictDoUpdate({ + target: [channelProjects.channelId, channelProjects.ownerUserId], + set: { projectId: project.id }, + }); + }, + }; +} diff --git a/server/src/user-preferences-routes.ts b/server/src/user-preferences-routes.ts index 066b979c02..ce6ace0274 100644 --- a/server/src/user-preferences-routes.ts +++ b/server/src/user-preferences-routes.ts @@ -1,4 +1,7 @@ -import { SKIN_IDS } from "@openbot/shared/user-preferences"; +import { + SIDEBAR_SECTION_IDS, + SKIN_IDS, +} from "@openbot/shared/user-preferences"; import { Hono, type MiddlewareHandler } from "hono"; import { z } from "zod"; import type { AppVariables } from "./auth/guards"; @@ -16,6 +19,17 @@ const preferencesPatch = z narration: z.enum(["on", "off"]).optional(), skin: z.enum(SKIN_IDS).optional(), proactive: z.enum(["on", "off"]).optional(), + /* + * All three flags or none: the row is merged as one JSON value, so a single-key object would + * replace the others with nothing and every other section would spring open. + */ + sidebarSections: z + .strictObject({ + group: z.boolean(), + direct: z.boolean(), + projects: z.boolean(), + }) + .optional(), }) .refine((patch) => Object.keys(patch).length > 0); @@ -47,7 +61,7 @@ export function userPreferencesRoutes( if (!parsed.success) { return context.json( { - error: `Choose agent or thread for message list emphasis, true or false for the self-host banner, on or off for narration or check-ins, or one of these skins: ${SKIN_IDS.join(", ")}.`, + error: `Choose agent or thread for message list emphasis, true or false for the self-host banner, on or off for narration or check-ins, true or false for each of these sidebar sections — ${SIDEBAR_SECTION_IDS.join(", ")} — or one of these skins: ${SKIN_IDS.join(", ")}.`, }, 400, ); diff --git a/server/src/user-preferences.ts b/server/src/user-preferences.ts index eb47025a08..13c758955a 100644 --- a/server/src/user-preferences.ts +++ b/server/src/user-preferences.ts @@ -1,4 +1,5 @@ import { + DEFAULT_SIDEBAR_SECTIONS, DEFAULT_USER_PREFERENCES, isSkinId, type UserPreferences, @@ -12,7 +13,19 @@ import { users } from "./db/schema"; * back to a build that has it brings it back, but this build answers with its default. */ function withDefaults(stored: Partial): UserPreferences { - const preferences = { ...DEFAULT_USER_PREFERENCES, ...stored }; + const preferences = { + ...DEFAULT_USER_PREFERENCES, + ...stored, + /* + * Merged a level deeper than the rest: the sidebar's flags arrive as one object, and a row + * written by a build that knew two sections must not leave the third undefined. A section this + * build does not draw is simply not read. + */ + sidebarSections: { + ...DEFAULT_SIDEBAR_SECTIONS, + ...stored.sidebarSections, + }, + }; return isSkinId(preferences.skin) ? preferences : { ...preferences, skin: DEFAULT_USER_PREFERENCES.skin }; diff --git a/server/tests/channel-activity.integration.test.ts b/server/tests/channel-activity.integration.test.ts index 03045fde09..b304e15bf8 100644 --- a/server/tests/channel-activity.integration.test.ts +++ b/server/tests/channel-activity.integration.test.ts @@ -252,6 +252,9 @@ describe("channel activity", () => { // The person who started it. canRename: true, task: false, + // One person, one Bot: a direct chat. The creator is its only member. + memberCount: 1, + projectId: null, }, ]); }); diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts index ed6ac2c8f4..e4e8512478 100644 --- a/server/tests/channel-routes.test.ts +++ b/server/tests/channel-routes.test.ts @@ -229,6 +229,8 @@ describe("what a channel is waiting on", () => { lastReadAt: null, canRename: true, task: false, + memberCount: 1, + projectId: null, }); const store = fakeStore({ list: async () => ({ @@ -263,6 +265,8 @@ describe("what a channel is waiting on", () => { lastReadAt: null, canRename: true, task, + memberCount: 1, + projectId: null, }); const store = fakeStore({ list: async () => ({ diff --git a/server/tests/projects.integration.test.ts b/server/tests/projects.integration.test.ts new file mode 100644 index 0000000000..74e7c8e49d --- /dev/null +++ b/server/tests/projects.integration.test.ts @@ -0,0 +1,519 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import type { MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import type { AppVariables, AuthenticatedActor } from "../src/auth/guards"; +import { createDatabase } from "../src/db/client"; +import { + channelMemberships, + channelProjects, + channels, + projects, + users, +} from "../src/db/schema"; +import { + createChannelProjectRoutes, + createProjectRoutes, +} from "../src/projects/routes"; +import { createProjectStore } from "../src/projects/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/** + * Project folders: who may rename and delete one, what may be filed into it, and what a folder is + * allowed to say about the conversations inside it. + * + * A folder is one person's filing and carries no permission of its own (see + * `src/db/schema/projects.ts`). Everything a folder can be refused for is therefore decided in the + * project store's WHERE clauses and in the membership join that fills `channelIds`, and a test that + * asserted against an in-memory stand-in for the store would pass with every one of those clauses + * deleted. So this file runs the real store against PostgreSQL and the real routers over it, and + * every refusal is checked twice: the status the browser gets, and the rows the database still + * holds afterwards. A stranger's write is asserted to be 404 (never 403, so the answer does not + * confirm the folder exists, and never 500, which is what an unguarded `update` on somebody else's + * row would have produced). + * + * Faked: nothing. The conversation is a row in `channels` with a `channel_memberships` row, which + * is exactly what a real conversation is before agents and transcripts enter the picture, and the + * subject here is filing rather than conversation lifecycle. + * + * How to run it, from the repository root: + * + * docker run -d --name openbot-test-pg -e POSTGRES_DB=openbot_test -e POSTGRES_USER=openbot \ + * -e POSTGRES_PASSWORD=openbot -p 127.0.0.1:55432:5432 pgvector/pgvector:pg17 + * DATABASE_URL=postgres://openbot:openbot@127.0.0.1:55432/openbot_test bun server/scripts/migrate.ts + * TEST_DATABASE_URL=postgres://openbot:openbot@127.0.0.1:55432/openbot_test \ + * bun test server/tests/projects.integration.test.ts + * + * `TEST_DATABASE_URL` is refused unless it names a dedicated database (`support/database.ts`), so + * the run can never reach the developer's own `openbot` rows. + */ + +const databaseUrl = testDatabaseUrl(); +const database = createDatabase(databaseUrl, TEST_POOL); +const projectStore = createProjectStore(database); +const suite = `projects-${randomUUID().slice(0, 8)}`; + +const createdUserIds: string[] = []; +const createdChannelIds: string[] = []; + +afterEach(async () => { + for (const channelId of createdChannelIds.splice(0)) { + await database.delete(channels).where(eq(channels.id, channelId)); + } + for (const userId of createdUserIds.splice(0)) { + await database.delete(users).where(eq(users.id, userId)); + } +}); + +afterAll(async () => { + await database.$client.close(); +}); + +/** One person, as the session resolves them. */ +async function person(name = "Project Test"): Promise { + const id = `${suite}-user-${randomUUID()}`; + const email = `${id}@example.test`; + await database.insert(users).values({ id, email, name }); + createdUserIds.push(id); + return { id, email, name, role: "user" }; +} + +/** A conversation, named the way the sidebar would draw it. */ +async function conversation(title: string): Promise { + const id = `channel_${randomUUID()}`; + await database.insert(channels).values({ id, name: title, description: "" }); + createdChannelIds.push(id); + return id; +} + +/** What `channel_memberships` is for: the one row every channel route resolves its caller in. */ +async function memberOf(channelId: string, userId: string): Promise { + await database.insert(channelMemberships).values({ channelId, userId }); +} + +type ProjectPayload = { + id: string; + name: string; + sortOrder: number; + createdAt: string; + updatedAt: string; + channelIds: string[]; +}; + +/** The browser's side: the four requests it makes, mounted over the real store. */ +function browserFor(actor: AuthenticatedActor) { + const requireUser: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", actor); + await next(); + }; + const app = new Hono<{ Variables: AppVariables }>(); + app.route("/api/projects", createProjectRoutes(projectStore, requireUser)); + app.route( + "/api/channels", + createChannelProjectRoutes(projectStore, requireUser), + ); + + const json = { "content-type": "application/json" }; + + return { + async create(name: string) { + const response = await app.request("/api/projects", { + method: "POST", + headers: json, + body: JSON.stringify({ name }), + }); + return { + status: response.status, + body: (await response.json()) as { + project?: ProjectPayload; + error?: string; + }, + }; + }, + /** The sidebar's read: this person's folders and the conversations filed in them. */ + async list() { + const response = await app.request("/api/projects"); + expect(response.status).toBe(200); + const text = await response.text(); + return { text, body: JSON.parse(text) as { projects: ProjectPayload[] } }; + }, + async rename(projectId: string, name: unknown) { + const response = await app.request(`/api/projects/${projectId}`, { + method: "PATCH", + headers: json, + body: JSON.stringify({ name }), + }); + return { + status: response.status, + body: (await response.json()) as { + project?: ProjectPayload; + error?: string; + }, + }; + }, + async remove(projectId: string) { + const response = await app.request(`/api/projects/${projectId}`, { + method: "DELETE", + }); + return { status: response.status }; + }, + /** `projectId: null` is the menu's "remove from project". */ + async move(channelId: string, projectId: string | null) { + const response = await app.request(`/api/channels/${channelId}/project`, { + method: "PATCH", + headers: json, + body: JSON.stringify({ projectId }), + }); + return { + status: response.status, + body: (await response.json().catch(() => null)) as { + error?: string; + } | null, + }; + }, + }; +} + +/** The folder row, or undefined when it is gone. */ +async function folderRow(projectId: string) { + const [row] = await database + .select({ + id: projects.id, + name: projects.name, + ownerUserId: projects.ownerUserId, + }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + return row; +} + +/** Every filing row for one conversation, whoever made it. */ +async function filingsOf(channelId: string) { + return await database + .select({ + projectId: channelProjects.projectId, + ownerUserId: channelProjects.ownerUserId, + }) + .from(channelProjects) + .where(eq(channelProjects.channelId, channelId)); +} + +describe("who a folder belongs to", () => { + test("its owner renames it and reads the new name back", async () => { + const owner = await person(); + const browser = browserFor(owner); + + // The create form sends what was typed, newlines and all; the row is one trimmed line. + const created = await browser.create(" Q3 planning\nnotes "); + expect(created.status).toBe(201); + const projectId = created.body.project?.id ?? ""; + expect(created.body.project?.name).toBe("Q3 planning notes"); + expect(created.body.project?.channelIds).toEqual([]); + + const renamed = await browser.rename(projectId, "Hiring"); + expect(renamed.status).toBe(200); + expect(renamed.body.project?.name).toBe("Hiring"); + + // Written, not echoed: the row is what a reload reads. + expect((await folderRow(projectId))?.name).toBe("Hiring"); + expect((await browser.list()).body.projects.map((p) => p.name)).toEqual([ + "Hiring", + ]); + }); + + test("a stranger's rename is refused as not found and changes nothing", async () => { + const owner = await person(); + const stranger = await person(); + const projectId = + (await browserFor(owner).create("Board")).body.project?.id ?? ""; + + const refused = await browserFor(stranger).rename(projectId, "Mine now"); + + // 404 rather than 403: the answer must not confirm that somebody else's folder exists. What it + // must never be is a 500, which is what an ownership check added after the update would give. + expect(refused.status).toBe(404); + expect(refused.status).not.toBe(500); + expect(refused.body.error).toBe("Couldn't find that project."); + expect((await folderRow(projectId ?? ""))?.name).toBe("Board"); + // And the stranger has nothing to see either. + expect((await browserFor(stranger).list()).body.projects).toEqual([]); + }); + + test("a stranger's delete leaves the folder and everything filed in it", async () => { + const owner = await person(); + const stranger = await person(); + const browser = browserFor(owner); + const projectId = (await browser.create("Board")).body.project?.id ?? ""; + const channelId = await conversation("Q3 headcount"); + await memberOf(channelId, owner.id); + expect((await browser.move(channelId, projectId)).status).toBe(204); + + const refused = await browserFor(stranger).remove(projectId); + + expect(refused.status).toBe(404); + expect(refused.status).not.toBe(500); + expect((await folderRow(projectId))?.name).toBe("Board"); + expect(await filingsOf(channelId)).toEqual([ + { projectId, ownerUserId: owner.id }, + ]); + }); + + test("its owner deletes it, and the folder is gone from the list", async () => { + const owner = await person(); + const browser = browserFor(owner); + const projectId = (await browser.create("Board")).body.project?.id ?? ""; + + const removed = await browser.remove(projectId); + + expect(removed.status).toBe(204); + expect(await folderRow(projectId)).toBeUndefined(); + expect((await browser.list()).body.projects).toEqual([]); + }); + + test("a folder name is required, one line, and short enough for a row", async () => { + const owner = await person(); + const browser = browserFor(owner); + + expect((await browser.create(" ")).status).toBe(400); + // A body that is not an object is a 400 as well, not a 500 from reading `.name` off it. + const notAnObject = await browserFor(owner).rename("missing", ["Board"]); + expect(notAnObject.status).toBe(400); + + const projectId = (await browser.create("Board")).body.project?.id ?? ""; + const tooLong = await browser.rename(projectId, "x".repeat(61)); + expect(tooLong.status).toBe(400); + expect((await folderRow(projectId))?.name).toBe("Board"); + }); +}); + +describe("filing a conversation into a folder", () => { + test("a member files a conversation, moves it, and takes it out", async () => { + const owner = await person(); + const browser = browserFor(owner); + const planning = (await browser.create("Planning")).body.project?.id ?? ""; + const hiring = (await browser.create("Hiring")).body.project?.id ?? ""; + const channelId = await conversation("Q3 headcount"); + await memberOf(channelId, owner.id); + + expect((await browser.move(channelId, planning)).status).toBe(204); + expect(await filingsOf(channelId)).toEqual([ + { projectId: planning, ownerUserId: owner.id }, + ]); + expect( + (await browser.list()).body.projects.flatMap((p) => p.channelIds), + ).toEqual([channelId]); + + // Filing it somewhere else MOVES it: one person files one conversation once. + expect((await browser.move(channelId, hiring)).status).toBe(204); + expect(await filingsOf(channelId)).toEqual([ + { projectId: hiring, ownerUserId: owner.id }, + ]); + + expect((await browser.move(channelId, null)).status).toBe(204); + expect(await filingsOf(channelId)).toEqual([]); + }); + + test("a non-member filing somebody else's conversation is refused as not found", async () => { + const owner = await person(); + const stranger = await person(); + const projectId = + (await browserFor(stranger).create("Snooping")).body.project?.id ?? ""; + const channelId = await conversation("Board salaries Q3"); + await memberOf(channelId, owner.id); + + const refused = await browserFor(stranger).move(channelId, projectId); + + // The channel route's own rule, reached through the filing: a stranger is not told whether the + // conversation exists or was deleted. Not a 500 from inserting a membership-less channel. + expect(refused.status).toBe(404); + expect(refused.status).not.toBe(500); + expect(refused.body?.error).toBe("Couldn't find that conversation."); + expect(await filingsOf(channelId)).toEqual([]); + expect( + (await browserFor(stranger).list()).body.projects.flatMap( + (p) => p.channelIds, + ), + ).toEqual([]); + }); + + test("a member cannot file a conversation into somebody else's folder", async () => { + const owner = await person(); + const colleague = await person(); + const channelId = await conversation("Q3 headcount"); + // Both people are in the conversation, so the membership gate is passed and the folder's + // ownership is the only thing left to refuse them. + await memberOf(channelId, owner.id); + await memberOf(channelId, colleague.id); + const ownersFolder = + (await browserFor(owner).create("Board")).body.project?.id ?? ""; + expect((await browserFor(owner).move(channelId, ownersFolder)).status).toBe( + 204, + ); + + // Same conversation, somebody else's folder id: refusing this keeps a folder from being filled + // by anyone but the person whose sidebar it is. + const refused = await browserFor(colleague).move(channelId, ownersFolder); + expect(refused.status).toBe(404); + expect(refused.status).not.toBe(500); + expect(refused.body?.error).toBe("Couldn't find that project."); + // The owner's filing is untouched by the attempt, and the colleague's own sidebar is empty. + expect(await filingsOf(channelId)).toEqual([ + { projectId: ownersFolder, ownerUserId: owner.id }, + ]); + expect( + (await browserFor(colleague).list()).body.projects.flatMap( + (p) => p.channelIds, + ), + ).toEqual([]); + }); +}); + +describe("what a folder is allowed to say", () => { + test("it names a conversation only while its owner can still open it", async () => { + const owner = await person(); + const browser = browserFor(owner); + const projectId = (await browser.create("Board")).body.project?.id ?? ""; + const mine = await conversation("Q3 headcount"); + const takenAway = await conversation("Former team retro"); + await memberOf(mine, owner.id); + await memberOf(takenAway, owner.id); + expect((await browser.move(mine, projectId)).status).toBe(204); + expect((await browser.move(takenAway, projectId)).status).toBe(204); + + // Membership withdrawn after the filing: the store refuses a new filing like this, so the row + // goes in directly to stand for a person removed from a group while their folder remembered it. + await database + .delete(channelMemberships) + .where( + and( + eq(channelMemberships.channelId, takenAway), + eq(channelMemberships.userId, owner.id), + ), + ); + + const folder = (await browser.list()).body.projects.find( + (p) => p.id === projectId, + ); + expect(folder?.channelIds).toEqual([mine]); + }); + + test("it carries conversation ids and never a conversation title", async () => { + const owner = await person(); + const browser = browserFor(owner); + const projectId = (await browser.create("Board")).body.project?.id ?? ""; + const titled = "Board salaries Q3 — confidential"; + const channelId = await conversation(titled); + await memberOf(channelId, owner.id); + expect((await browser.move(channelId, projectId)).status).toBe(204); + + const { text, body } = await browser.list(); + + // The title is what a person who cannot open the conversation must never learn, and ids are the + // one shape of answer that cannot carry it even if a filter above were wrong. + expect(text).not.toContain(titled); + expect(text).not.toContain("confidential"); + expect(Object.keys(body.projects[0] ?? {}).sort()).toEqual([ + "channelIds", + "createdAt", + "id", + "name", + "sortOrder", + "updatedAt", + ]); + expect(body.projects[0]?.channelIds).toEqual([channelId]); + }); + + test("somebody else's folders are not in the answer at all", async () => { + const owner = await person(); + const stranger = await person(); + const projectId = + (await browserFor(owner).create("Board")).body.project?.id ?? ""; + + const { text, body } = await browserFor(stranger).list(); + + expect(body.projects).toEqual([]); + expect(text).not.toContain(projectId); + }); +}); + +describe("deleting a folder", () => { + test("returns its conversations, still filed where anybody else put them", async () => { + const owner = await person(); + const colleague = await person(); + const channelId = await conversation("Q3 headcount"); + await memberOf(channelId, owner.id); + await memberOf(channelId, colleague.id); + const ownersBrowser = browserFor(owner); + const ownersFolder = + (await ownersBrowser.create("Board")).body.project?.id ?? ""; + const colleaguesFolder = + (await browserFor(colleague).create("Team")).body.project?.id ?? ""; + expect((await ownersBrowser.move(channelId, ownersFolder)).status).toBe( + 204, + ); + expect( + (await browserFor(colleague).move(channelId, colleaguesFolder)).status, + ).toBe(204); + + expect((await ownersBrowser.remove(ownersFolder)).status).toBe(204); + + /* + * A folder files a conversation through a row in `channel_projects`; a channel has no + * `project_id` column to clear (see db/schema/projects.ts, and why). So "the project_id is taken + * off" is that row being gone, and the other person's filing of the same conversation being + * still there — the same conversation filed in two folders, one of which was deleted. + */ + expect(await filingsOf(channelId)).toEqual([ + { projectId: colleaguesFolder, ownerUserId: colleague.id }, + ]); + + // The conversation itself is untouched: deleting a folder is a sidebar act, not a delete. + const [channel] = await database + .select({ + id: channels.id, + name: channels.name, + deletedAt: channels.deletedAt, + }) + .from(channels) + .where(eq(channels.id, channelId)) + .limit(1); + expect(channel).toEqual({ + id: channelId, + name: "Q3 headcount", + deletedAt: null, + }); + expect( + await database + .select({ userId: channelMemberships.userId }) + .from(channelMemberships) + .where(eq(channelMemberships.channelId, channelId)), + ).toHaveLength(2); + + // Both sidebars agree afterwards: the folder is gone for its owner, and the conversation is + // still filed for the colleague. + const owners = await ownersBrowser.list(); + expect(owners.body.projects).toEqual([]); + expect(owners.text).not.toContain(channelId); + expect( + (await browserFor(colleague).list()).body.projects.flatMap( + (p) => p.channelIds, + ), + ).toEqual([channelId]); + + // And it can be filed again, into a folder made after the delete. + const planning = + (await ownersBrowser.create("Planning")).body.project?.id ?? ""; + expect((await ownersBrowser.move(channelId, planning)).status).toBe(204); + expect(await filingsOf(channelId)).toEqual( + expect.arrayContaining([ + { projectId: planning, ownerUserId: owner.id }, + { projectId: colleaguesFolder, ownerUserId: colleague.id }, + ]), + ); + }); +}); diff --git a/server/tests/user-preferences.integration.test.ts b/server/tests/user-preferences.integration.test.ts index 69e4de4dce..6e0cb2663d 100644 --- a/server/tests/user-preferences.integration.test.ts +++ b/server/tests/user-preferences.integration.test.ts @@ -40,6 +40,7 @@ test("database defaults and writes survive a new store and remain scoped to the narration: null, skin: "openbot", proactive: "on", + sidebarSections: { group: false, direct: false, projects: false }, }); expect(await store.patch(alice, { messageListEmphasis: "agent" })).toEqual({ messageListEmphasis: "agent", @@ -47,6 +48,7 @@ test("database defaults and writes survive a new store and remain scoped to the narration: null, skin: "openbot", proactive: "on", + sidebarSections: { group: false, direct: false, projects: false }, }); expect(await createUserPreferencesStore(database).read(alice)).toEqual({ messageListEmphasis: "agent", @@ -54,6 +56,7 @@ test("database defaults and writes survive a new store and remain scoped to the narration: null, skin: "openbot", proactive: "on", + sidebarSections: { group: false, direct: false, projects: false }, }); expect(await store.read(bob)).toEqual({ messageListEmphasis: "thread", @@ -61,6 +64,7 @@ test("database defaults and writes survive a new store and remain scoped to the narration: null, skin: "openbot", proactive: "on", + sidebarSections: { group: false, direct: false, projects: false }, }); }); @@ -99,6 +103,7 @@ test("the narration choice is kept in the database beside the others", async () narration: "on", skin: "openbot", proactive: "on", + sidebarSections: { group: false, direct: false, projects: false }, }); }); @@ -110,6 +115,7 @@ test("the skin is saved on the account and survives a new store", async () => { narration: null, skin: "openmuse", proactive: "on", + sidebarSections: { group: false, direct: false, projects: false }, }); expect((await createUserPreferencesStore(database).read(id)).skin).toBe( "openmuse", @@ -140,6 +146,7 @@ test("check-ins are on for a person who never chose, and their off is kept in th narration: null, skin: "openduet", proactive: "off", + sidebarSections: { group: false, direct: false, projects: false }, }); // The jsonb key the check-in claim filters on, as Postgres holds it. const [row] = await database diff --git a/shared/user-preferences.ts b/shared/user-preferences.ts index ac071988b5..0aad52f7f2 100644 --- a/shared/user-preferences.ts +++ b/shared/user-preferences.ts @@ -25,6 +25,27 @@ export function isSkinId(value: unknown): value is SkinId { return SKIN_IDS.some((id) => id === value); } +/** + * The collapsible sections of the conversation sidebar, in the order they are drawn. + * + * Shared rather than app-local because which sections a person keeps folded is saved on their + * account: it follows them to every browser and device they sign in from, and the server has to + * know the values it may store. + */ +export const SIDEBAR_SECTION_IDS = ["group", "direct", "projects"] as const; + +export type SidebarSectionId = (typeof SIDEBAR_SECTION_IDS)[number]; + +/** One flag per section: true means folded shut. */ +export type SidebarSections = Record; + +/** Everything open, which is what a person sees before they fold anything. */ +export const DEFAULT_SIDEBAR_SECTIONS: SidebarSections = { + group: false, + direct: false, + projects: false, +}; + export type UserPreferences = { /** Server-owned first new eligible conversation; shared by both starter Bots. */ starterTourChannelId?: string; @@ -47,6 +68,13 @@ export type UserPreferences = { * on, off and interval stay as they were, so turning this back on restores them. */ proactive: ProactivePreference; + /** + * Which sidebar sections this person has folded shut. + * + * On the account, not in the browser's storage, for the same reason the skin is: a person who + * folds Projects on their laptop expects it folded on their desktop too. + */ + sidebarSections: SidebarSections; }; export const DEFAULT_USER_PREFERENCES: UserPreferences = { @@ -55,4 +83,5 @@ export const DEFAULT_USER_PREFERENCES: UserPreferences = { narration: null, skin: DEFAULT_SKIN, proactive: "on", + sidebarSections: DEFAULT_SIDEBAR_SECTIONS, };