From c4cc4f2664497808509c315e8b08f1f014c0d70b Mon Sep 17 00:00:00 2001 From: Ayal Kleinman Date: Tue, 25 Aug 2026 22:30:52 -0700 Subject: [PATCH] feat: an attention inbox for refusals and stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A boundary refusal or a stalled run was recorded and then waited for somebody to happen to look — at the right channel, or at the audit page only an administrator has. The trail knew; nobody was told. The inbox is a view over the trail, not a second record of it. Refusals (computer.action_refused, mcp.call_rejected) and stalls (agent.stream_stalled) are already written transactionally by the gateway and the stall guard, so deriving the inbox from those rows means it cannot miss one: there is no dual write to drift, and nothing new runs on the action path. The only state it owns is the resolution — who marked a row handled, and when — in a table beside the append-only trail rather than in it, ids by value with no foreign keys for the trail's own documented reason. GET /api/attention composes recent rows minus resolutions, then scopes per item by the same canUseBot the roster and the computer use; an administrator sees everything the way they see every Bot. Not under /api/admin: the audit page is the administrator looking back, the inbox is the working person being told now. POST /api/attention/:eventId/resolve marks one handled for everyone, with attribution. First writer wins by unique index rather than check-then-write, and the second presser is read back who got there first. Only a row of the three attention kinds resolves; anything else answers the same 404, so the endpoint cannot be used to probe what the trail holds. Which Bot a row is about is not where it looks: a tool rejection's target is the TOOL — targetType "mcp_tool", targetId the ref — and its Bot travels only in the payload. Reading targetId unconditionally called a refusal's Bot "google-drive/search_files", which canUseBot correctly denies, which hid every tool rejection from exactly the person it was for. botOf reads targetId only for computer and agent rows, and a row that cannot name its Bot is dropped rather than shown to everybody. In the app: an Attention page listing what is open with Resolve on each row, and a sidebar entry with a count badge drawn only when nonzero. --- CHANGELOG.md | 16 + .../components/app-sidebar/app-sidebar.tsx | 34 + app/src/lib/attention/mutations.ts | 27 + app/src/lib/attention/queries.ts | 34 + app/src/routeTree.gen.ts | 21 + app/src/routes/_authed/_app/attention.tsx | 130 + server/drizzle.config.ts | 1 + server/drizzle/0016_attention_resolutions.sql | 8 + server/drizzle/meta/0016_snapshot.json | 2601 +++++++++++++++++ server/drizzle/meta/_journal.json | 9 +- server/src/app.ts | 20 + server/src/attention/routes.ts | 88 + server/src/attention/store.ts | 106 + server/src/attention/view.ts | 108 + server/src/db/schema/attention.ts | 33 + server/src/db/schema/index.ts | 1 + server/src/index.ts | 3 + .../tests/attention-store.integration.test.ts | 56 + server/tests/attention-view.test.ts | 110 + 19 files changed, 3405 insertions(+), 1 deletion(-) create mode 100644 app/src/lib/attention/mutations.ts create mode 100644 app/src/lib/attention/queries.ts create mode 100644 app/src/routes/_authed/_app/attention.tsx create mode 100644 server/drizzle/0016_attention_resolutions.sql create mode 100644 server/drizzle/meta/0016_snapshot.json create mode 100644 server/src/attention/routes.ts create mode 100644 server/src/attention/store.ts create mode 100644 server/src/attention/view.ts create mode 100644 server/src/db/schema/attention.ts create mode 100644 server/tests/attention-store.integration.test.ts create mode 100644 server/tests/attention-view.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cbee9e84..705c570e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A Bot in trouble no longer needs somebody watching + +A boundary refusal or a stalled run was recorded and then waited for a person to happen to look — at +the right channel, or at the audit page an administrator has and nobody else does. The trail knew; +nobody was told. + +**Attention**, in the sidebar for everybody, shows the refusals and stalled runs nobody has handled +yet, scoped to the Bots this person may use, with a badge saying how many. Marking one handled +clears it for everyone and records who did; two people pressing Resolve at once is settled by the +database rather than by luck, and the second is told who got there first. + +It is a view over the trail, not a second record of it. Refusals and stalls are already written +transactionally by the gateway and the stall guard, so the inbox cannot miss one and nothing new +runs on the action path. The only state it owns is the resolution, held beside the append-only trail +rather than in it. The trail itself still keeps everything; the inbox is only what is open now. + ### Knowledge searches instead of guessing A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index b6e8d2dc..f00ac409 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -1,4 +1,5 @@ import { + IconBellRinging, IconBolt, IconBox, IconLogout, @@ -40,6 +41,7 @@ import { SidebarRail, } from "@/components/ui/sidebar"; import { signOutMutationOptions } from "@/lib/auth/mutations"; +import { attentionListQueryOptions } from "@/lib/attention/queries"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { type ChannelSummary, @@ -152,6 +154,9 @@ function ChannelRow({ export function AppSidebar({ ...props }: React.ComponentProps) { const { data: currentUser } = useQuery(currentUserQueryOptions()); + // Unhandled attention items this person may see; drawn as a badge only when nonzero. + const attentionCount = + useQuery(attentionListQueryOptions()).data?.length ?? 0; const queryClient = useQueryClient(); const navigate = useNavigate(); const signOut = useMutation(signOutMutationOptions(queryClient)); @@ -269,6 +274,35 @@ export function AppSidebar({ ...props }: React.ComponentProps) { + + {/* + * Above Skills because it is the row that can be urgent. The count is the number of + * unhandled items this person may see; zero draws no badge, because an empty inbox + * asking for attention is the boy who cried wolf. + */} + ( + + )} + > +
+ +
+ Attention + {attentionCount > 0 ? ( + + {attentionCount} + + ) : null} +
+
{/* Beside Agents rather than inside Admin: writing a skill is something anybody does. */} => + client( + `/api/attention/${encodeURIComponent(eventId)}/resolve`, + "resolution", + { + method: "POST", + fallback: "The item could not be marked handled.", + }, + ), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: attentionKeys.all }), + }); +} diff --git a/app/src/lib/attention/queries.ts b/app/src/lib/attention/queries.ts new file mode 100644 index 00000000..5f83a325 --- /dev/null +++ b/app/src/lib/attention/queries.ts @@ -0,0 +1,34 @@ +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +/** One trail row that means a Bot is waiting on a person. */ +export type AttentionItem = { + /** The trail row's id; resolving cites the exact row. */ + id: string; + kind: "refused" | "tool_rejected" | "stalled"; + botId: string; + at: string; + /** One sentence a person can act on, written by whatever recorded the row. */ + sentence: string; +}; + +export const attentionKeys = { + all: ["attention"] as const, + list: () => ["attention", "list"] as const, +}; + +/** + * Polled the way grants are: often enough that a Bot in trouble is noticed inside a minute, and + * refetched on focus so coming back to the tab answers immediately. + */ +export function attentionListQueryOptions() { + return queryOptions({ + queryKey: attentionKeys.list(), + refetchInterval: 15_000, + refetchOnWindowFocus: true, + queryFn: (): Promise => + client("/api/attention", "items", { + fallback: "The attention list could not be loaded.", + }), + }); +} diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 5cb09822..058da25e 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as AuthedAppRouteImport } from './routes/_authed/_app' import { Route as AuthedAdminRouteRouteImport } from './routes/_authed/admin/route' import { Route as AuthedSettingsRouteRouteImport } from './routes/_authed/settings/route' import { Route as AuthedAppIndexRouteImport } from './routes/_authed/_app/index' +import { Route as AuthedAppAttentionRouteImport } from './routes/_authed/_app/attention' import { Route as AuthedAppBotRouteImport } from './routes/_authed/_app/bot' import { Route as AuthedAppSkillsRouteImport } from './routes/_authed/_app/skills' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' @@ -68,6 +69,11 @@ const AuthedAppIndexRoute = AuthedAppIndexRouteImport.update({ path: '/', getParentRoute: () => AuthedAppRoute, } as any) +const AuthedAppAttentionRoute = AuthedAppAttentionRouteImport.update({ + id: '/attention', + path: '/attention', + getParentRoute: () => AuthedAppRoute, +} as any) const AuthedAppBotRoute = AuthedAppBotRouteImport.update({ id: '/bot', path: '/bot', @@ -203,6 +209,7 @@ export interface FileRoutesByFullPath { '/sign': typeof SignRoute '/admin': typeof AuthedAdminRouteRouteWithChildren '/settings': typeof AuthedSettingsRouteRouteWithChildren + '/attention': typeof AuthedAppAttentionRoute '/bot': typeof AuthedAppBotRoute '/skills': typeof AuthedAppSkillsRoute '/admin/audit': typeof AuthedAdminAuditRoute @@ -231,6 +238,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof AuthedAppIndexRoute '/sign': typeof SignRoute + '/attention': typeof AuthedAppAttentionRoute '/bot': typeof AuthedAppBotRoute '/skills': typeof AuthedAppSkillsRoute '/admin/audit': typeof AuthedAdminAuditRoute @@ -263,6 +271,7 @@ export interface FileRoutesById { '/_authed/admin': typeof AuthedAdminRouteRouteWithChildren '/_authed/settings': typeof AuthedSettingsRouteRouteWithChildren '/_authed/_app': typeof AuthedAppRouteWithChildren + '/_authed/_app/attention': typeof AuthedAppAttentionRoute '/_authed/_app/bot': typeof AuthedAppBotRoute '/_authed/_app/skills': typeof AuthedAppSkillsRoute '/_authed/admin/audit': typeof AuthedAdminAuditRoute @@ -296,6 +305,7 @@ export interface FileRouteTypes { | '/sign' | '/admin' | '/settings' + | '/attention' | '/bot' | '/skills' | '/admin/audit' @@ -324,6 +334,7 @@ export interface FileRouteTypes { to: | '/' | '/sign' + | '/attention' | '/bot' | '/skills' | '/admin/audit' @@ -355,6 +366,7 @@ export interface FileRouteTypes { | '/_authed/admin' | '/_authed/settings' | '/_authed/_app' + | '/_authed/_app/attention' | '/_authed/_app/bot' | '/_authed/_app/skills' | '/_authed/admin/audit' @@ -431,6 +443,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAppIndexRouteImport parentRoute: typeof AuthedAppRoute } + '/_authed/_app/attention': { + id: '/_authed/_app/attention' + path: '/attention' + fullPath: '/attention' + preLoaderRoute: typeof AuthedAppAttentionRouteImport + parentRoute: typeof AuthedAppRoute + } '/_authed/_app/bot': { id: '/_authed/_app/bot' path: '/bot' @@ -663,6 +682,7 @@ const AuthedSettingsRouteRouteWithChildren = AuthedSettingsRouteRoute._addFileChildren(AuthedSettingsRouteRouteChildren) interface AuthedAppRouteChildren { + AuthedAppAttentionRoute: typeof AuthedAppAttentionRoute AuthedAppBotRoute: typeof AuthedAppBotRoute AuthedAppSkillsRoute: typeof AuthedAppSkillsRoute AuthedAppIndexRoute: typeof AuthedAppIndexRoute @@ -672,6 +692,7 @@ interface AuthedAppRouteChildren { } const AuthedAppRouteChildren: AuthedAppRouteChildren = { + AuthedAppAttentionRoute: AuthedAppAttentionRoute, AuthedAppBotRoute: AuthedAppBotRoute, AuthedAppSkillsRoute: AuthedAppSkillsRoute, AuthedAppIndexRoute: AuthedAppIndexRoute, diff --git a/app/src/routes/_authed/_app/attention.tsx b/app/src/routes/_authed/_app/attention.tsx new file mode 100644 index 00000000..f8fba2dd --- /dev/null +++ b/app/src/routes/_authed/_app/attention.tsx @@ -0,0 +1,130 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { + IconAlertTriangle, + IconHandStop, + IconPlugOff, +} from "@tabler/icons-react"; +import { + PageRows, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import type { AttentionItem } from "@/lib/attention/queries"; +import { attentionListQueryOptions } from "@/lib/attention/queries"; +import { resolveAttentionMutationOptions } from "@/lib/attention/mutations"; +import { queryClient } from "@/query-client"; + +/** + * What is waiting on a person: boundary refusals and stalled runs, drawn from the trail and gone + * once somebody marks them handled. The trail itself keeps everything; this page is only what is + * open now. + */ + +export const Route = createFileRoute("/_authed/_app/attention")({ + component: AttentionPage, +}); + +const KIND_WORDS: Record = { + refused: "Action refused", + tool_rejected: "Tool call refused", + stalled: "Run stalled", +}; + +function KindIcon({ kind }: { kind: AttentionItem["kind"] }) { + if (kind === "stalled") return ; + if (kind === "tool_rejected") return ; + return ; +} + +function AttentionPage() { + const items = useQuery(attentionListQueryOptions()); + const resolve = useMutation(resolveAttentionMutationOptions(queryClient)); + + return ( + + Refusals and stalled runs that nobody has handled yet. Everything here + is already recorded in the trail; marking an item handled clears it + for everyone and says who did. + + } + title="Attention" + > + + {items.isPending ? null : items.error ? ( +

+ The attention list could not be loaded. +

+ ) : items.data?.length === 0 ? ( +

+ Nothing is waiting. A refusal or a stalled run will appear here. +

+ ) : ( + + {items.data?.map((item, index) => ( +
+ {index > 0 ? : null} + + + + + + + {KIND_WORDS[item.kind]} · {item.botId} + + + {item.sentence}{" "} + + {new Date(item.at).toLocaleString()} + + + + + + + + +
+ ))} +
+ )} + {resolve.error ? ( +

+ {resolve.error.message} +

+ ) : null} +
+
+ ); +} diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index 24e6775d..50046b77 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "./src/db/schema/coworker.ts", "./src/db/schema/components.ts", "./src/db/schema/plugins.ts", + "./src/db/schema/attention.ts", ], out: "./drizzle", dbCredentials: { diff --git a/server/drizzle/0016_attention_resolutions.sql b/server/drizzle/0016_attention_resolutions.sql new file mode 100644 index 00000000..015b7657 --- /dev/null +++ b/server/drizzle/0016_attention_resolutions.sql @@ -0,0 +1,8 @@ +CREATE TABLE "attention_resolutions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "audit_event_id" uuid NOT NULL, + "resolved_by" text NOT NULL, + "resolved_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "attention_resolutions_event_idx" ON "attention_resolutions" USING btree ("audit_event_id"); \ No newline at end of file diff --git a/server/drizzle/meta/0016_snapshot.json b/server/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000..800d6d89 --- /dev/null +++ b/server/drizzle/meta/0016_snapshot.json @@ -0,0 +1,2601 @@ +{ + "id": "5f5bc7dc-de6d-4b42-8aa4-df4bb0787ac3", + "prevId": "8f1f91d5-9cb1-490a-a28f-a0a3f15b9614", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attention_resolutions": { + "name": "attention_resolutions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "audit_event_id": { + "name": "audit_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attention_resolutions_event_idx": { + "name": "attention_resolutions_event_idx", + "columns": [ + { + "expression": "audit_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + } + }, + "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 1162cae9..c0096de0 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1787525879804, "tag": "0015_credentials_one_live_key", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1787721805823, + "tag": "0016_attention_resolutions", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/server/src/app.ts b/server/src/app.ts index edd47dff..fe655195 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -33,6 +33,8 @@ import { createSandboxedRoutes } from "./components/sandboxed-routes"; import type { ComponentStore } from "./components/store"; import type { ComputerGateway } from "./computer/gateway"; import type { PolicyStore } from "./computer/policy-store"; +import { createAttentionRoutes } from "./attention/routes"; +import type { AttentionStore } from "./attention/store"; import { createComputerRoutes } from "./computer/routes"; import { configuredAuthProviders, type DeploymentConfig } from "./config"; import type { CredentialAdminService, CredentialInput } from "./credentials"; @@ -155,6 +157,12 @@ export function createApp( * the default coworker, which is exactly the failsafe the router itself falls back to. */ intentRouter?: IntentRouter, + /** + * Resolutions for the attention inbox. Built beside the other stores in index.ts. Absent leaves + * the inbox unmounted rather than degraded: an inbox that cannot subtract what has been handled + * would show everything forever, and one that cannot see the trail would show a false all-quiet. + */ + attentionStore?: AttentionStore, ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -634,6 +642,18 @@ export function createApp( ); } + /* + * The attention inbox: what on the trail is waiting for a person. Needs the trail to read and the + * database for resolutions, and without either it is not mounted — an inbox that cannot see + * refusals is not a reduced feature, it is a false "all quiet". + */ + if (auditReader && attentionStore) { + app.route( + "/api/attention", + createAttentionRoutes(auditReader, attentionStore, requireUser, canUseBot), + ); + } + if (agentProfileStore) { app.route( "/api/agents", diff --git a/server/src/attention/routes.ts b/server/src/attention/routes.ts new file mode 100644 index 00000000..68652fcb --- /dev/null +++ b/server/src/attention/routes.ts @@ -0,0 +1,88 @@ +/** + * The attention inbox over HTTP. + * + * Deliberately not `/api/admin/...`: the audit page is the administrator's view of everything, and + * is gated accordingly. The inbox is the working person's view of their own Bots' trouble, so it is + * scoped per item by the same `canUseBot` the rest of the surface uses, and an administrator sees + * all of it the way they see all Bots. + */ + +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import type { BotAccessCheck } from "../agents/profile-policy"; +import type { AuditReader } from "../audit"; +import type { AppVariables } from "../auth/guards"; +import type { AttentionStore } from "./store"; +import { ATTENTION_EVENT_TYPES, attentionItemsFrom, botOf } from "./view"; + +/** + * How much trail the view reads. Bounded and biased to recency for the same reason the policy + * dry-run is: the inbox answers "what needs me now", and an unresolved refusal from beyond this + * window is answered by the Audit page, which exists for looking back. + */ +const SCAN_LIMIT = 200; + +export function createAttentionRoutes( + auditReader: AuditReader, + store: AttentionStore, + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, + canUseBot: BotAccessCheck, +) { + const routes = new Hono<{ Variables: AppVariables }>(); + + routes.get("/", requireUser, async (context) => { + const { events } = await auditReader.list({ + limit: SCAN_LIMIT, + eventType: ATTENTION_EVENT_TYPES.join(","), + }); + const resolved = await store.resolvedAmong(events.map((one) => one.id)); + const items = attentionItemsFrom(events, resolved); + + if (context.var.actor.role === "admin") { + return context.json({ items }); + } + /* + * Scoped per item rather than per request: one inbox can name several Bots, and which of them + * this person may see is the store's question, asked with the same check the roster and the + * computer use. Sequential on purpose — the distinct Bots in a 200-row window are few, and the + * memo keeps it to one ask per Bot. + */ + const allowed = new Map(); + const visible = []; + for (const item of items) { + let may = allowed.get(item.botId); + if (may === undefined) { + may = await canUseBot(context.var.actor, item.botId); + allowed.set(item.botId, may); + } + if (may) visible.push(item); + } + return context.json({ items: visible }); + }); + + routes.post("/:eventId/resolve", requireUser, async (context) => { + const eventId = context.req.param("eventId"); + const event = await store.event(eventId); + /* + * Only a real attention row can be resolved. Anything else — a made-up id, a trail row of some + * other kind — answers the same way, so this cannot be used to probe what the trail holds. + */ + const kinds: readonly string[] = ATTENTION_EVENT_TYPES; + if (!event || !kinds.includes(event.eventType)) { + return context.json({ error: "There is no such attention item." }, 404); + } + // The same reading the view uses: a tool rejection's target is the tool, not the Bot. + const botId = botOf(event); + if ( + context.var.actor.role !== "admin" && + !(botId && (await canUseBot(context.var.actor, botId))) + ) { + return context.json({ error: "There is no such attention item." }, 404); + } + + const resolution = await store.resolve(eventId, context.var.actor.id); + return context.json({ resolution }); + }); + + return routes; +} diff --git a/server/src/attention/store.ts b/server/src/attention/store.ts new file mode 100644 index 00000000..feebbb5c --- /dev/null +++ b/server/src/attention/store.ts @@ -0,0 +1,106 @@ +/** + * The one piece of state the inbox owns: which trail rows a person has marked handled. + */ + +import { eq, inArray } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { attentionResolutions, auditEvents } from "../db/schema"; + +export type AttentionResolution = { + auditEventId: string; + resolvedBy: string; + resolvedAt: string; +}; + +export type AttentionStore = { + /** The resolved ids among these, for subtracting from the view. */ + resolvedAmong(eventIds: string[]): Promise>; + /** The trail row itself, so a resolve can check what it is resolving. */ + event(eventId: string): Promise<{ + id: string; + eventType: string; + targetType: string; + targetId: string | null; + payload: Record; + } | null>; + /** + * Mark handled. First writer wins by unique index — not by check-then-write — so two replicas + * cannot both believe they resolved it. Returns the resolution that stands, and whether this call + * is the one that wrote it. + */ + resolve( + eventId: string, + userId: string, + ): Promise; +}; + +export function createAttentionStore(database: Database): AttentionStore { + return { + resolvedAmong: async (eventIds) => { + if (eventIds.length === 0) return new Set(); + const rows = await database + .select({ auditEventId: attentionResolutions.auditEventId }) + .from(attentionResolutions) + .where(inArray(attentionResolutions.auditEventId, eventIds)); + return new Set(rows.map((row) => row.auditEventId)); + }, + + event: async (eventId) => { + const rows = await database + .select({ + id: auditEvents.id, + eventType: auditEvents.eventType, + targetType: auditEvents.targetType, + targetId: auditEvents.targetId, + payload: auditEvents.payload, + }) + .from(auditEvents) + .where(eq(auditEvents.id, eventId)) + .limit(1); + const row = rows[0]; + return row + ? { ...row, payload: row.payload as Record } + : null; + }, + + resolve: async (eventId, userId) => { + const inserted = await database + .insert(attentionResolutions) + .values({ auditEventId: eventId, resolvedBy: userId }) + .onConflictDoNothing({ target: attentionResolutions.auditEventId }) + .returning({ + auditEventId: attentionResolutions.auditEventId, + resolvedBy: attentionResolutions.resolvedBy, + resolvedAt: attentionResolutions.resolvedAt, + }); + const row = inserted[0]; + if (row) { + return { + auditEventId: row.auditEventId, + resolvedBy: row.resolvedBy, + resolvedAt: row.resolvedAt.toISOString(), + alreadyResolved: false, + }; + } + // The conflict path: somebody got there first. Read back who, which is the answer the second + // presser actually wants. + const standing = await database + .select() + .from(attentionResolutions) + .where(eq(attentionResolutions.auditEventId, eventId)) + .limit(1); + const existing = standing[0]; + if (!existing) { + // Conflict on insert and absent on read means a concurrent resolve was rolled back between + // the two statements. Vanishingly rare; the honest report is a retryable failure. + throw new Error("The resolution could not be read back. Try again."); + } + return { + auditEventId: existing.auditEventId, + resolvedBy: existing.resolvedBy, + resolvedAt: existing.resolvedAt.toISOString(), + alreadyResolved: true, + }; + }, + }; +} diff --git a/server/src/attention/view.ts b/server/src/attention/view.ts new file mode 100644 index 00000000..3a766b9f --- /dev/null +++ b/server/src/attention/view.ts @@ -0,0 +1,108 @@ +/** + * The attention inbox: the trail rows that mean a Bot is waiting on a person, minus the ones a + * person has already handled. + * + * A view, not a store. Refusals and stalls are already recorded transactionally by the gateway and + * the stall guard, so deriving the inbox from those rows means it cannot miss one: there is no + * second write to forget, and nothing here runs on the action path. The only state the inbox owns + * is the resolution — who marked a row handled, and when — which lives beside the trail rather than + * in it, because the trail is append-only and must stay that way. + */ + +import type { AuditEvent } from "../audit"; + +/** The trail rows that mean "a Bot is waiting on a person". In one place, for the query. */ +export const ATTENTION_EVENT_TYPES = [ + "computer.action_refused", + "mcp.call_rejected", + "agent.stream_stalled", +] as const; + +export type AttentionKind = "refused" | "tool_rejected" | "stalled"; + +export type AttentionItem = { + /** The trail row's own id: resolving cites the exact row, not a copy of it. */ + id: string; + kind: AttentionKind; + botId: string; + at: string; + /** One sentence a person can act on, built from what the trail recorded. */ + sentence: string; +}; + +const KIND_BY_EVENT_TYPE: Record = { + "computer.action_refused": "refused", + "mcp.call_rejected": "tool_rejected", + "agent.stream_stalled": "stalled", +}; + +const text = (value: unknown): string => + typeof value === "string" ? value : ""; + +/** + * The Bot a row is about. The three event types write it differently, and the difference is not + * cosmetic: the computer and the stall guard put the Bot in `targetId`, but a tool rejection's + * target is the TOOL — `targetType: "mcp_tool"`, `targetId` the ref — and its Bot travels only in + * the payload. Reading `targetId` unconditionally made the inbox call a refusal's Bot + * "google-drive/search_files", which is not a Bot, which `canUseBot` correctly denies, which hid + * every tool rejection from exactly the person it was for. + */ +export function botOf(event: Pick): string { + if (event.targetType === "computer" || event.targetType === "agent") { + return event.targetId ?? text(event.payload.bot); + } + return text(event.payload.bot); +} + +/** What to tell the person. Prefers the sentence the recording code already wrote for one. */ +function sentenceFor(event: AuditEvent, kind: AttentionKind): string { + const payload = event.payload; + if (kind === "stalled") { + return "The Bot's stream went quiet mid-turn and the run was ended."; + } + if (kind === "tool_rejected") { + // The rejection records the decision's own reason, written for a person. Use it whole. + const reason = text(payload.reason); + if (reason) return reason; + const tool = text(payload.tool) || "a tool"; + return `A call to ${tool} was refused by this deployment's boundary.`; + } + // A computer refusal records the gateway's own reason, written for a person. Use it whole. + const decision = + payload.decision && typeof payload.decision === "object" + ? (payload.decision as Record) + : null; + const reason = text(decision?.reason); + if (reason) return reason; + const action = text(payload.action) || "an action"; + return `${action} was refused by this deployment's boundary.`; +} + +/** + * Compose the inbox from trail rows and the set of resolved row ids. + * + * Rows without a Bot are dropped rather than guessed at: an item that cannot say whose trouble it + * is cannot be scoped to a person, and showing it to everybody would leak across the same line + * `canUseBot` exists to hold. + */ +export function attentionItemsFrom( + events: AuditEvent[], + resolvedEventIds: ReadonlySet, +): AttentionItem[] { + const items: AttentionItem[] = []; + for (const event of events) { + const kind = KIND_BY_EVENT_TYPE[event.eventType]; + if (!kind) continue; + if (resolvedEventIds.has(event.id)) continue; + const botId = botOf(event); + if (!botId) continue; + items.push({ + id: event.id, + kind, + botId, + at: event.createdAt, + sentence: sentenceFor(event, kind), + }); + } + return items; +} diff --git a/server/src/db/schema/attention.ts b/server/src/db/schema/attention.ts new file mode 100644 index 00000000..ddd74ecd --- /dev/null +++ b/server/src/db/schema/attention.ts @@ -0,0 +1,33 @@ +import { pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; + +/** + * A trail row somebody has marked handled. + * + * The attention inbox is a view over the audit trail — refusals and stalls are already recorded + * there, transactionally, by the gateway and the stall guard. What the trail cannot say is that a + * person has dealt with one, because the trail is append-only and must stay that way. So resolution + * is state ABOUT a trail row, held beside it: the row itself is never touched. + * + * Ids by value, no foreign keys, for the trail's own reason (see `actorUserId` on `audit_events`): + * a cascade against an append-only table is an update the trigger refuses, and a person who had + * ever resolved anything could otherwise never be deleted. + */ +export const attentionResolutions = pgTable( + "attention_resolutions", + { + id: uuid("id").primaryKey().defaultRandom(), + auditEventId: uuid("audit_event_id").notNull(), + resolvedBy: text("resolved_by").notNull(), + resolvedAt: timestamp("resolved_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + /** + * One resolution per trail row, enforced where two replicas cannot disagree about it. Two + * people pressing Resolve at once is not a race to be lost: the second insert conflicts, and + * the caller reads back who got there first. + */ + uniqueIndex("attention_resolutions_event_idx").on(table.auditEventId), + ], +); diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts index b924af64..d18820d1 100644 --- a/server/src/db/schema/index.ts +++ b/server/src/db/schema/index.ts @@ -1,5 +1,6 @@ /** One import path for every table, with schema files grouped by owner. */ +export * from "./attention"; export * from "./components"; export * from "./computer"; export * from "./core"; diff --git a/server/src/index.ts b/server/src/index.ts index 4b7f2bf6..93f5e8c7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -43,6 +43,7 @@ import { createCredentialStore, resolveModelApiKey, } from "./credentials"; +import { createAttentionStore } from "./attention/store"; import { createDatabase } from "./db/client"; import { createPeopleStore } from "./people/store"; import { createPluginStore } from "./plugins/store"; @@ -541,6 +542,8 @@ const app = createApp( identityProviderStore, // Chooses the coworker for an untagged message, on the deployment's own model and key. intentRouter, + // Resolutions for the attention inbox: which trail rows a person has marked handled. + createAttentionStore(database), ); /** diff --git a/server/tests/attention-store.integration.test.ts b/server/tests/attention-store.integration.test.ts new file mode 100644 index 00000000..2983a33e --- /dev/null +++ b/server/tests/attention-store.integration.test.ts @@ -0,0 +1,56 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { inArray } from "drizzle-orm"; +import { createAttentionStore } from "../src/attention/store"; +import { createDatabase } from "../src/db/client"; +import { attentionResolutions } from "../src/db/schema"; +import { TEST_POOL } from "./support/database"; + +const databaseUrl = + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot"; +const database = createDatabase(databaseUrl, TEST_POOL); +const store = createAttentionStore(database); + +/** Rows this file wrote, removed on the way out. Resolutions are not the trail; they may be. */ +const written: string[] = []; + +afterAll(async () => { + if (written.length > 0) { + await database + .delete(attentionResolutions) + .where(inArray(attentionResolutions.auditEventId, written)); + } + await database.$client.close(); +}); + +describe("attention resolutions", () => { + test("the first resolver wins and the second is told who did", async () => { + const eventId = randomUUID(); + written.push(eventId); + + const first = await store.resolve(eventId, "person-a"); + expect(first.alreadyResolved).toBe(false); + expect(first.resolvedBy).toBe("person-a"); + + // The race, replayed: the unique index answers, not a check-then-write. + const second = await store.resolve(eventId, "person-b"); + expect(second.alreadyResolved).toBe(true); + expect(second.resolvedBy).toBe("person-a"); + }); + + test("resolvedAmong answers exactly the resolved subset", async () => { + const resolved = randomUUID(); + const pending = randomUUID(); + written.push(resolved); + + await store.resolve(resolved, "person-a"); + const answer = await store.resolvedAmong([resolved, pending]); + expect(answer.has(resolved)).toBe(true); + expect(answer.has(pending)).toBe(false); + }); + + test("an empty ask does not touch the database", async () => { + expect((await store.resolvedAmong([])).size).toBe(0); + }); +}); diff --git a/server/tests/attention-view.test.ts b/server/tests/attention-view.test.ts new file mode 100644 index 00000000..38f1830d --- /dev/null +++ b/server/tests/attention-view.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import type { AuditEvent } from "../src/audit"; +import { attentionItemsFrom } from "../src/attention/view"; + +/** + * The inbox must show exactly the trail rows that mean "a Bot is waiting on a person", minus what + * has been handled — and must drop rather than guess when a row cannot say whose trouble it is. + */ + +function event(overrides: Partial): AuditEvent { + return { + id: overrides.id ?? "evt-1", + actorUserId: null, + eventType: overrides.eventType ?? "computer.action_refused", + targetType: overrides.targetType ?? "computer", + targetId: overrides.targetId === undefined ? "general-assistant" : overrides.targetId, + payload: overrides.payload ?? {}, + createdAt: overrides.createdAt ?? "2026-08-25T00:00:00.000Z", + }; +} + +describe("attentionItemsFrom", () => { + test("a computer refusal carries the gateway's own reason, whole", () => { + const items = attentionItemsFrom( + [ + event({ + payload: { + decision: { reason: "“Submit order” on shop.example is blocked." }, + }, + }), + ], + new Set(), + ); + expect(items).toHaveLength(1); + expect(items[0]?.kind).toBe("refused"); + expect(items[0]?.sentence).toBe("“Submit order” on shop.example is blocked."); + expect(items[0]?.botId).toBe("general-assistant"); + }); + + test("a tool rejection's Bot is the payload's, never the tool ref in targetId", () => { + // The real row shape: targetType mcp_tool, targetId the tool ref, the Bot in the payload. + const items = attentionItemsFrom( + [ + event({ + eventType: "mcp.call_rejected", + targetType: "mcp_tool", + targetId: "jira/jira_create_issue", + payload: { + bot: "risk-analyst", + tool: "jira_create_issue", + reason: "This Bot holds no grant for that tool.", + }, + }), + ], + new Set(), + ); + expect(items[0]?.kind).toBe("tool_rejected"); + expect(items[0]?.botId).toBe("risk-analyst"); + // The decision's own sentence, whole. + expect(items[0]?.sentence).toBe("This Bot holds no grant for that tool."); + }); + + test("a tool rejection without a payload Bot is dropped, not attributed to the tool", () => { + const items = attentionItemsFrom( + [ + event({ + eventType: "mcp.call_rejected", + targetType: "mcp_tool", + targetId: "jira/jira_create_issue", + payload: { tool: "jira_create_issue" }, + }), + ], + new Set(), + ); + expect(items).toHaveLength(0); + }); + + test("a stall is one plain sentence", () => { + const items = attentionItemsFrom( + [event({ eventType: "agent.stream_stalled", targetType: "agent" })], + new Set(), + ); + expect(items[0]?.kind).toBe("stalled"); + expect(items[0]?.sentence).toContain("quiet"); + }); + + test("a resolved row is subtracted", () => { + const items = attentionItemsFrom( + [event({ id: "handled" }), event({ id: "pending" })], + new Set(["handled"]), + ); + expect(items.map((item) => item.id)).toEqual(["pending"]); + }); + + test("a row that cannot name its Bot is dropped, not shown to everybody", () => { + const items = attentionItemsFrom( + [event({ targetId: null, payload: {} })], + new Set(), + ); + expect(items).toHaveLength(0); + }); + + test("event types outside the three are ignored even if handed in", () => { + const items = attentionItemsFrom( + [event({ eventType: "computer.action_allowed" })], + new Set(), + ); + expect(items).toHaveLength(0); + }); +});