diff --git a/apps/hub/package.json b/apps/hub/package.json index b37d51141..6f1575e88 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@corbits/agent-directory": "workspace:*", + "@corbits/approvals": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/folded-runs": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 59d4ef571..313ce9201 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -51,6 +51,7 @@ import { } from "@intx/hub-sessions"; import { getLogger, setup } from "@intx/log"; import { hexEncode } from "@intx/types"; +import { createNeedsYouRoutes } from "@corbits/approvals"; import { createEchoRoutes } from "@workbench/echo"; import { createGitWorkflowPusher } from "@workbench/hub-client"; import { createOnboardingRoutes } from "@workbench/onboarding"; @@ -213,6 +214,20 @@ export async function createHub(config: HubConfig) { // runs with c.get("tenant") / c.get("principal") resolved. app.route(`${TENANT_PREFIX}/echo`, createEchoRoutes()); + // The "needs you" list: the same `approval:*`/"resolve" grant Interchange's + // own approve/reject routes require, layered with the agent/bench names + // this tenant's approvals don't carry on their own. Approving and + // rejecting still go straight to Interchange's native routes below -- + // this route only ever reads. + app.route( + `${TENANT_PREFIX}/approvals/needs-you`, + createNeedsYouRoutes({ + db, + grantStore: createGrantStore(db), + conditionRegistry: { time_window: timeWindowEvaluator }, + }), + ); + // Chat's own grant store/condition registry, built the same way // `createApp` builds its default when none is supplied (see // `@intx/hub-api`'s `mountHubRoutes`): a db-backed grant store and diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index a8fb758a1..446e21b4a 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -19,10 +19,41 @@ export const PrincipalsSchema = paginatedSchema(PrincipalSummary); export const RunsSchema = paginatedSchema(WorkflowRunSummary); export const TenantApprovalsSchema = paginatedSchema(ApprovalResponse); +// `@corbits/approvals`'s "needs you" read: the same pending approvals as +// `TenantApprovalsSchema`, but with the agent and bench names already +// resolved server-side, so nothing here ever needs a raw id to render. +export const NeedsYouSchema = type({ + items: type({ + id: "string", + agentName: "string", + benchName: "string", + headline: "string", + arguments: "object", + status: '"pending"', + createdAt: "string.date.iso", + }).array(), +}); + export type Profile = typeof UserProfile.infer; export type Principal = typeof PrincipalSummary.infer; export type WorkflowRun = typeof WorkflowRunSummary.infer; export type Approval = typeof ApprovalResponse.infer; +export type NeedsYou = typeof NeedsYouSchema.infer; +export type NeedsYouItem = NeedsYou["items"][number]; + +/** + * How many things need this bench's attention right now — the count the + * second column's "Approvals" row badges. `null` while unknown (no bench + * selected yet, or the read hasn't resolved), so a caller never mistakes + * "still loading" for "zero pending." + */ +export function useNeedsYouCount(tenantId: string | null): number | null { + const query = useAPIQuery( + tenantId === null ? "" : `/api/tenants/${tenantId}/approvals/needs-you`, + NeedsYouSchema, + ); + return query.kind === "ready" ? query.data.items.length : null; +} /** * The envelope paginatedSchema validates, stated structurally: the generic diff --git a/apps/web/src/optional-props.ts b/apps/web/src/optional-props.ts index e08a33d85..75b573903 100644 --- a/apps/web/src/optional-props.ts +++ b/apps/web/src/optional-props.ts @@ -4,6 +4,8 @@ // helpers build the omitting form once instead of forking every JSX call // site into two branches. +import type { ReactNode } from "react"; + export function countProp(count: number | undefined): { count?: number } { return count === undefined ? {} : { count }; } @@ -13,3 +15,11 @@ export function subtitleProp(subtitle: string | undefined): { } { return subtitle === undefined ? {} : { subtitle }; } + +export function metaProp(meta: ReactNode | undefined): { meta?: ReactNode } { + return meta === undefined ? {} : { meta }; +} + +export function badgeProp(badge: ReactNode | undefined): { badge?: ReactNode } { + return badge === undefined ? {} : { badge }; +} diff --git a/apps/web/src/pages/approvals-page.tsx b/apps/web/src/pages/approvals-page.tsx index b3e3adc78..d5e1ba0e9 100644 --- a/apps/web/src/pages/approvals-page.tsx +++ b/apps/web/src/pages/approvals-page.tsx @@ -1,10 +1,14 @@ -// Approvals, fanned out per-bench: `GET /api/me/approvals` is a hub stub -// that always returns `[]` (see the tenancy inventory's gap list), so this -// reads the current bench's pending approvals from the real, tenant-scoped -// `GET /api/tenants/:tenantId/approvals` instead. Approve only offers scope -// "once" — the hub rejects "always" with a 400 because a standing grant -// needs the tool identity the suspend path doesn't capture yet — and reject -// collects an optional message before resolving. +// Approvals, fanned out per-bench: the list reads +// `GET /api/tenants/:tenantId/approvals/needs-you` (`@corbits/approvals`), +// which resolves each pending approval's agent and bench names so nothing +// here ever renders a raw agent address or run id. Approve/reject still +// post straight to Interchange's own +// `/api/tenants/:tenantId/approvals/:id/{approve,reject}` routes, keyed by +// the same `id` the needs-you list carries — resolving stays exactly-once +// and grant-scoped there, this page only ever composes the display. Approve +// only offers scope "once" — the hub rejects "always" with a 400 because a +// standing grant needs the tool identity the suspend path doesn't capture +// yet — and reject collects an optional message before resolving. import { ApprovalCard, @@ -28,19 +32,14 @@ import { useState } from "react"; import { approveApproval, rejectApproval, - TenantApprovalsSchema, + NeedsYouSchema, useAPIQuery, } from "../api"; import { countProp } from "../optional-props"; -import type { APIQuery, Approval } from "../api"; +import type { APIQuery, NeedsYouItem } from "../api"; import { useBench } from "../bench-context"; import { QueryView } from "../query-view"; -function approvalHeadline(approval: Approval): string { - const toolName = approval.toolDefinition["name"]; - return typeof toolName === "string" ? toolName : "Run a tool"; -} - export function ApprovalsPage({ approvals, onApprove, @@ -49,14 +48,14 @@ export function ApprovalsPage({ rejectingId = null, actionError = null, }: { - readonly approvals: APIQuery; - readonly onApprove: (approval: Approval) => void; - readonly onReject: (approval: Approval, message?: string) => void; + readonly approvals: APIQuery; + readonly onApprove: (approval: NeedsYouItem) => void; + readonly onReject: (approval: NeedsYouItem, message?: string) => void; readonly approvingId?: string | null; readonly rejectingId?: string | null; readonly actionError?: string | null; }) { - const [rejectTarget, setRejectTarget] = useState(null); + const [rejectTarget, setRejectTarget] = useState(null); return ( <> @@ -84,9 +83,9 @@ export function ApprovalsPage({ {rows.map((approval) => { const request: ApprovalRequest = { id: approval.id, - headline: approvalHeadline(approval), - requestedBy: approval.agentAddress, - details: Object.entries(approval.toolArguments).map( + headline: approval.headline, + requestedBy: `${approval.agentName} in ${approval.benchName}`, + details: Object.entries(approval.arguments).map( ([label, value]) => ({ label, value: @@ -141,7 +140,7 @@ function RejectDialog({ onClose, onConfirm, }: { - readonly approval: Approval | null; + readonly approval: NeedsYouItem | null; readonly onClose: () => void; readonly onConfirm: (message?: string) => void; }) { @@ -203,22 +202,22 @@ export function ApprovalsRoute() { const approvals = useAPIQuery( selectedTenantId === null ? "" - : `/api/tenants/${selectedTenantId}/approvals`, - TenantApprovalsSchema, + : `/api/tenants/${selectedTenantId}/approvals/needs-you`, + NeedsYouSchema, reloadKey, ); - const rows: APIQuery = + const rows: APIQuery = selectedTenantId === null ? { kind: "loading" } : approvals.kind === "ready" - ? { kind: "ready", data: approvals.data.data } + ? { kind: "ready", data: approvals.data.items } : approvals; function reload() { setReloadKey((value) => value + 1); } - function handleApprove(approval: Approval) { + function handleApprove(approval: NeedsYouItem) { if (selectedTenantId === null) return; setActionError(null); setApprovingId(approval.id); @@ -228,7 +227,7 @@ export function ApprovalsRoute() { .finally(() => setApprovingId(null)); } - function handleReject(approval: Approval, message?: string) { + function handleReject(approval: NeedsYouItem, message?: string) { if (selectedTenantId === null) return; setActionError(null); setRejectingId(approval.id); diff --git a/apps/web/src/shell/rail.tsx b/apps/web/src/shell/rail.tsx index 686318728..5633ad0f0 100644 --- a/apps/web/src/shell/rail.tsx +++ b/apps/web/src/shell/rail.tsx @@ -8,12 +8,17 @@ // The footer still composes the bench switcher and identity docks the rail // needs below the page icons. -import { SidebarRail } from "@corbits/react-ui"; +import { Badge, SidebarRail } from "@corbits/react-ui"; +import { useNeedsYouCount } from "../api"; +import { useBench } from "../bench-context"; +import { badgeProp } from "../optional-props"; import { NAV_ROUTES, matchesRoute, type AppRoute } from "../routes"; import type { SessionUser } from "../session"; import { BenchDock, RailIdentity } from "./docks"; +const APPROVALS_PATH = "/approvals"; + export function Rail({ path, onNavigate, @@ -31,6 +36,16 @@ export function Rail({ const activeRoute = NAV_ROUTES.find((route) => matchesRoute(route.path, path), ); + const { selectedTenantId } = useBench(); + // Which running workflows are parked waiting on this bench's approval — + // Interchange's own "needs you" state, read through `@corbits/approvals`. + // After the page list moved onto the rail, the count badges the Approvals + // icon itself (`SidebarRailItem.badge`), not a contextual-panel row. + const needsYouCount = useNeedsYouCount(selectedTenantId); + const needsYouBadge = + needsYouCount !== null && needsYouCount > 0 ? ( + {needsYouCount} + ) : undefined; return ( { test("approvals says nothing is waiting", () => { const markup = renderToStaticMarkup( ([])} + approvals={ready([])} onApprove={() => undefined} onReject={() => undefined} />, ); expect(markup).toContain("No approvals waiting"); }); + + test("approvals renders resolved agent/bench names, never a raw agent address or run id", () => { + const item: NeedsYouItem = { + id: "apr_1", + agentName: "Outreach Composer", + benchName: "Growth Team Bench", + headline: "send_email", + arguments: { to: "customer@example.com" }, + status: "pending", + createdAt: "2026-01-01T00:00:00.000Z", + }; + const markup = renderToStaticMarkup( + ([item])} + onApprove={() => undefined} + onReject={() => undefined} + />, + ); + expect(markup).toContain("Outreach Composer"); + expect(markup).toContain("Growth Team Bench"); + expect(markup).not.toContain("apr_1"); + }); }); describe("signed-out state", () => { diff --git a/apps/web/test/shell-contextual-panel.test.tsx b/apps/web/test/shell-contextual-panel.test.tsx new file mode 100644 index 000000000..05bdb0e4d --- /dev/null +++ b/apps/web/test/shell-contextual-panel.test.tsx @@ -0,0 +1,123 @@ +// The "needs you" count only means anything if it actually reaches the +// screen. After the page list moved onto the rail, the Approvals badge is +// `SidebarRailItem.badge` — not a `SidebarItemRow` `meta` slot on the +// contextual panel. This test renders the real rail tree against a live DOM +// and a mocked hub, so a wrong prop name shows up as a missing count in the +// rendered text, not just a type that happens to check. + +import { afterEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { BenchProvider } from "../src/bench-context"; +import { NavigationProvider } from "../src/navigation"; +import { Rail } from "../src/shell/rail"; + +const noop = () => undefined; +const user = { id: "user_1", name: "Ada Lovelace", email: "ada@example.com" }; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +let container: HTMLDivElement | null = null; +let root: Root | null = null; +let originalFetch: typeof fetch; + +afterEach(() => { + if (root !== null) act(() => root?.unmount()); + if (container !== null) container.remove(); + root = null; + container = null; + globalThis.fetch = originalFetch; +}); + +/** Stubs the two hub reads the rail triggers: bench membership (so + * `BenchProvider` resolves a selected tenant) and this tenant's needs-you + * list (so the Approvals item has something to badge). */ +function stubFetch(needsYouItemCount: number): void { + originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/me/principals") { + return jsonResponse({ + data: [ + { + principalId: "prn_1", + tenantId: "tnt_1", + tenantName: "Growth Team Bench", + tenantSlug: "growth-team", + kind: "user", + status: "active", + roles: [], + }, + ], + nextCursor: null, + }); + } + if (url === "/api/tenants/tnt_1/approvals/needs-you") { + const items = Array.from({ length: needsYouItemCount }, (_, i) => ({ + id: `apr_${i}`, + agentName: "Outreach Composer", + benchName: "Growth Team Bench", + headline: "send_email", + arguments: {}, + status: "pending", + createdAt: "2026-01-01T00:00:00.000Z", + })); + return jsonResponse({ items }); + } + throw new Error(`unexpected fetch in test: ${url}`); + }) as typeof fetch; +} + +async function renderRail(): Promise { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + + + + , + ); + // Two effect-driven fetches run one after the other (membership resolves + // a tenant id, which is what makes the needs-you effect fire at all), so + // this waits on a macrotask between each of several microtask turns + // rather than guessing a fixed microtask count. + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }); + if (container === null) throw new Error("container not mounted"); + return container; +} + +describe("Rail's Approvals item", () => { + test("badges the item with the real pending count once needs-you resolves", async () => { + stubFetch(3); + const el = await renderRail(); + expect(el.textContent).toContain("Approvals"); + expect(el.textContent).toContain("3"); + }); + + test("carries no badge when nothing is pending", async () => { + stubFetch(0); + const el = await renderRail(); + expect(el.textContent).toContain("Approvals"); + // Every other item's label is a bare word with no digits; the absence of + // any digit anywhere in the rail is the honest way to assert "no badge" + // without hard-coding the badge's own markup shape. + expect(el.textContent).not.toMatch(/[0-9]/); + }); +}); diff --git a/bun.lock b/bun.lock index c46bd0074..ea3993df2 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "version": "0.0.1", "dependencies": { "@corbits/agent-directory": "workspace:*", + "@corbits/approvals": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/folded-runs": "workspace:*", @@ -140,6 +141,25 @@ "typescript": "catalog:", }, }, + "packages/approvals": { + "name": "@corbits/approvals", + "version": "0.0.1", + "dependencies": { + "@intx/authz": "workspace:*", + "@intx/db": "workspace:*", + "@intx/hub-api": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + "drizzle-orm": "catalog:", + "hono": "^4.11.9", + }, + "devDependencies": { + "@intx/hub-common": "workspace:*", + "@types/bun": "catalog:", + "postgres": "catalog:", + "typescript": "catalog:", + }, + }, "packages/artifact-ui": { "name": "@corbits/artifact-ui", "version": "0.0.1", @@ -803,6 +823,8 @@ "@corbits/agent-lifecycle": ["@corbits/agent-lifecycle@workspace:packages/agent-lifecycle"], + "@corbits/approvals": ["@corbits/approvals@workspace:packages/approvals"], + "@corbits/artifact-ui": ["@corbits/artifact-ui@workspace:packages/artifact-ui"], "@corbits/assistant-workflow": ["@corbits/assistant-workflow@workspace:workflows/assistant"], diff --git a/docs/needs-you.md b/docs/needs-you.md new file mode 100644 index 000000000..d84267c9c --- /dev/null +++ b/docs/needs-you.md @@ -0,0 +1,65 @@ +# Needs you + +Approval is "needs you." When a running workflow parks on a human decision, +that pause is Interchange's own native state — an `approval` row backed by a +`signal_correlation` row, produced by `AwaitSignalPrimitive` — not a second +concept this repo invents alongside it. This document describes the one +piece Interchange's approval machinery doesn't carry on its own: turning a +pending approval into something a person can actually read. + +## What's native, unchanged + +Everything about deciding an approval is Interchange's, consumed as-is: + +- `GET /api/tenants/:tenantId/approvals` lists a tenant's pending approvals. +- `POST /api/tenants/:tenantId/approvals/:id/approve` and `.../reject` + resolve one. Both run inside a single transaction that claims the + approval's `signal_correlation` row under a `resolved_at IS NULL` guard + and only then flips the approval's status — so two concurrent decisions + (two clicks, two people) can never both land: the second finds nothing + left to claim and is told the approval is already resolved. +- Every resolve is authorized against the same grant an approver has always + needed: `approval:` (or the tenant-wide `approval:*`), + checked server-side before the transaction runs, never left to the + interface to hide a button. + +None of that changed. `@corbits/approvals` never creates, claims, or +resolves an approval — it only reads. + +## What's new: resolving names, not ids + +An `approval` row only carries what it needs to authorize and resume: a +`deploymentId`, a `runId`, an `agentAddress` with an instance id baked into +it. None of that is something a person should have to read. `GET +/api/tenants/:tenantId/approvals/needs-you` (`@corbits/approvals`, +`packages/approvals`) reads the same pending rows the native list route +does and resolves each one's real name before it ever reaches a client: + +- `runId -> workflow_run.definitionId -> workflow_definition.name` for + which agent is asking. +- `tenantId -> tenant.name` for which bench the ask is in. + +The result is a small, display-only view model — an agent name, a bench +name, the tool's headline, its arguments, nothing else — gated by the exact +same `approval:*` / `resolve` grant the native list route requires. A +principal without that grant gets a `403`, not an empty list dressed up as +"nothing pending." + +## Where it shows up + +The second column's "Approvals" row carries a live count of what's waiting +on the current bench, read from this same endpoint and rendered through +`SidebarItemRow`'s `meta` slot as a `Badge` — not a `count` prop, which this +version of `@corbits/react-ui` doesn't have. The Approvals page itself +renders each request as "`` in ``" instead of a raw +agent address, and still approves or rejects through +Interchange's native routes directly — this package only ever supplies the +names. + +## What this deliberately does not add + +No new "gate" table, no parallel resolution path, no reimplementation of +`claimTerminal` or the approve/reject transaction. Mail-based delivery +(notifying a human's inbox the moment an approval is created, rather than +this page polling for it) and reply-to-resume are real, valuable follow-ups +that build on this same `approval` row — neither is part of this change. diff --git a/packages/approvals/package.json b/packages/approvals/package.json new file mode 100644 index 000000000..f23d28acf --- /dev/null +++ b/packages/approvals/package.json @@ -0,0 +1,30 @@ +{ + "name": "@corbits/approvals", + "private": true, + "description": "Resolves the tenant's pending approvals (Interchange's own \"needs you\" state) into a display-safe view model, and exposes it as a Hono route factory the hub mounts alongside Interchange's native approve/reject routes", + "version": "0.0.1", + "license": "SEE LICENSE IN LICENSE.md", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@intx/authz": "workspace:*", + "@intx/db": "workspace:*", + "@intx/hub-api": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + "drizzle-orm": "catalog:", + "hono": "^4.11.9" + }, + "devDependencies": { + "@intx/hub-common": "workspace:*", + "@types/bun": "catalog:", + "postgres": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/approvals/src/index.ts b/packages/approvals/src/index.ts new file mode 100644 index 000000000..84d33397d --- /dev/null +++ b/packages/approvals/src/index.ts @@ -0,0 +1,3 @@ +export { createNeedsYouRoutes } from "./routes"; +export type { CreateNeedsYouRoutesDeps } from "./routes"; +export { NeedsYouItem, hydrateNeedsYou } from "./view-model"; diff --git a/packages/approvals/src/routes.ts b/packages/approvals/src/routes.ts new file mode 100644 index 000000000..f45a238fc --- /dev/null +++ b/packages/approvals/src/routes.ts @@ -0,0 +1,74 @@ +import { and, eq } from "drizzle-orm"; +import { Hono } from "hono"; + +import { authorize } from "@intx/authz"; +import type { DB } from "@intx/db"; +import { schema, parseApprovalRow } from "@intx/db"; +import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; +import type { TenantEnv } from "@intx/hub-api"; + +import { hydrateNeedsYou } from "./view-model"; + +export type CreateNeedsYouRoutesDeps = { + db: DB["db"]; + grantStore: GrantStore; + conditionRegistry: ConditionRegistry; +}; + +/** + * The one net-new domain concept this package adds: a display-ready read of + * "what needs this tenant's attention right now." It never creates, resolves, + * or claims anything -- approving and rejecting stay on Interchange's own + * `/api/tenants/:tenantId/approvals/:approvalId/{approve,reject}` routes, + * whose authorize + claimTerminal + approvalStore.resolve transaction is + * already exactly-once and already grant-scoped. Reimplementing that + * machinery here would be the parallel gate concept the design explicitly + * rejects; this route only adds the naming layer that machinery has no + * reason to own. + */ +export function createNeedsYouRoutes( + deps: CreateNeedsYouRoutesDeps, +): Hono { + const app = new Hono(); + + app.get("/", async (c) => { + const tenant = c.get("tenant"); + const principal = c.get("principal"); + + // Same grant and action the native list/resolve routes require -- + // whoever can resolve a tenant's approvals is who "needs you" is for. + // Reusing this string keeps the two surfaces from drifting apart. + const authz = await authorize( + deps.grantStore, + principal.id, + tenant.id, + "approval:*", + "resolve", + deps.conditionRegistry, + ); + if (authz.effect !== "allow") { + return c.json( + { + error: { + code: "forbidden", + message: "You do not have permission to see this bench's approvals", + }, + }, + 403, + ); + } + + const rows = await deps.db.query.approval.findMany({ + where: and( + eq(schema.approval.tenantId, tenant.id), + eq(schema.approval.status, "pending"), + ), + orderBy: (row, { asc }) => [asc(row.createdAt)], + }); + + const items = await hydrateNeedsYou(deps.db, rows.map(parseApprovalRow)); + return c.json({ items }); + }); + + return app; +} diff --git a/packages/approvals/src/view-model.ts b/packages/approvals/src/view-model.ts new file mode 100644 index 000000000..58c9e4ce6 --- /dev/null +++ b/packages/approvals/src/view-model.ts @@ -0,0 +1,94 @@ +import { inArray } from "drizzle-orm"; +import { type } from "arktype"; + +import type { DBExecutor } from "@intx/db"; +import { schema, parseApprovalRow } from "@intx/db"; + +type ApprovalRow = ReturnType; + +// The shape a human decides against. Every identifier the underlying +// `approval` row carries (agentAddress, deploymentId, tenantId) is resolved +// here into a name before this type's only two producers -- `hydrateNeedsYou` +// and its tests -- ever construct one, so nothing downstream can render a raw +// id even by accident: there is no field on this type that holds one. +export const NeedsYouItem = type({ + id: "string", + agentName: "string", + benchName: "string", + headline: "string", + arguments: "object", + status: '"pending"', + createdAt: "string.date.iso", +}); +export type NeedsYouItem = typeof NeedsYouItem.infer; + +function headlineFor(toolDefinition: unknown): string { + if ( + typeof toolDefinition === "object" && + toolDefinition !== null && + "name" in toolDefinition && + typeof (toolDefinition as { name: unknown }).name === "string" + ) { + return (toolDefinition as { name: string }).name; + } + return "Run a tool"; +} + +/** + * Resolves a page of pending `approval` rows into the display-safe view + * model a human approves or rejects. Every name is looked up through the + * definition/tenant the approval's own foreign keys already point at -- + * `workflow_run.definitionId -> workflow_definition.name` for "which agent + * is asking" and `approval.tenantId -> tenant.name` for "in which bench" -- + * so no new naming concept is introduced, only a read of names that already + * exist on the rows the approval is anchored to. + */ +export async function hydrateNeedsYou( + db: DBExecutor, + approvals: readonly ApprovalRow[], +): Promise { + if (approvals.length === 0) return []; + + const tenantIds = [...new Set(approvals.map((row) => row.tenantId))]; + const runIds = [...new Set(approvals.map((row) => row.runId))]; + + const [tenants, runs] = await Promise.all([ + db.query.tenant.findMany({ where: inArray(schema.tenant.id, tenantIds) }), + db.query.workflowRun.findMany({ + where: inArray(schema.workflowRun.id, runIds), + }), + ]); + + const tenantNameById = new Map(tenants.map((row) => [row.id, row.name])); + const definitionIdByRunId = new Map( + runs.map((row) => [row.id, row.definitionId]), + ); + const definitionIds = [...new Set(definitionIdByRunId.values())]; + const definitions = + definitionIds.length === 0 + ? [] + : await db.query.workflowDefinition.findMany({ + where: inArray(schema.workflowDefinition.id, definitionIds), + }); + const definitionNameById = new Map( + definitions.map((row) => [row.id, row.name]), + ); + + return approvals.map((row) => { + const definitionId = definitionIdByRunId.get(row.runId); + const agentName = + (definitionId !== undefined + ? definitionNameById.get(definitionId) + : undefined) ?? "An agent"; + const benchName = tenantNameById.get(row.tenantId) ?? "A bench"; + return { + id: row.id, + agentName, + benchName, + headline: headlineFor(row.toolDefinition), + arguments: row.toolArguments as object, + status: "pending" as const, + createdAt: row.createdAt.toISOString(), + }; + }); +} diff --git a/packages/approvals/test/needs-you.test.ts b/packages/approvals/test/needs-you.test.ts new file mode 100644 index 000000000..f0453e22f --- /dev/null +++ b/packages/approvals/test/needs-you.test.ts @@ -0,0 +1,203 @@ +// DB-gated: skipped when DATABASE_URL is unreachable, matching this repo's +// existing convention for tests that talk to a real Postgres (see +// packages/chat/test/migrations.test.ts). Runs against the caller's own +// database and deletes every row it wrote in afterAll, since the tables it +// touches (tenant, workflow_definition, workflow_run, approval) are shared +// platform tables this suite must not leave dirty for anything else that +// reads them. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Context, Next } from "hono"; + +import { createDB, schema, parseApprovalRow, type DB } from "@intx/db"; +import { createInMemoryGrantStore } from "@intx/authz"; +import type { TenantEnv, TenantRow, PrincipalRow } from "@intx/hub-api"; +import { generateId } from "@intx/hub-common"; + +import { createNeedsYouRoutes } from "../src/routes"; +import { hydrateNeedsYou } from "../src/view-model"; + +function dbConfigFromUrl(databaseUrl: string) { + const url = new URL(databaseUrl); + return { + host: url.hostname, + port: url.port === "" ? 5432 : Number(url.port), + user: decodeURIComponent(url.username || "postgres"), + password: decodeURIComponent(url.password), + database: url.pathname.replace(/^\//, ""), + }; +} + +const databaseUrl = process.env["DATABASE_URL"]; +const describeIfDb = databaseUrl === undefined ? describe.skip : describe; + +describeIfDb("needs-you: resolving pending approvals to display labels", () => { + let db: DB; + + const tenantId = generateId("tenant"); + const otherTenantId = generateId("tenant"); + const definitionId = `def_${generateId("deployment")}`; + const runId = `run_${generateId("deployment")}`; + const approvalId = generateId("approval"); + const approverPrincipalId = generateId("principal"); + const strangerPrincipalId = generateId("principal"); + + const tenantRow: TenantRow = { + id: tenantId, + name: "Growth Team Bench", + slug: `growth-${tenantId}`, + domain: `growth-${tenantId}.localhost`, + parentId: null, + config: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeAll(async () => { + db = createDB(dbConfigFromUrl(databaseUrl as string)); + + await db.db.insert(schema.tenant).values({ + id: tenantId, + name: "Growth Team Bench", + slug: `growth-${tenantId}`, + domain: `growth-${tenantId}.localhost`, + }); + await db.db.insert(schema.tenant).values({ + id: otherTenantId, + name: "Unrelated Bench", + slug: `unrelated-${otherTenantId}`, + domain: `unrelated-${otherTenantId}.localhost`, + }); + await db.db.insert(schema.workflowDefinition).values({ + id: definitionId, + tenantId, + name: "Outreach Composer", + status: "deployed", + }); + await db.db.insert(schema.workflowRun).values({ + id: runId, + definitionId, + tenantId, + status: "running", + }); + await db.db.insert(schema.approval).values({ + id: approvalId, + tenantId, + deploymentId: runId, + runId, + agentAddress: `instance_abc123@growth-${tenantId}.localhost`, + correlationId: `cor_${generateId("signal")}`, + toolDefinition: { name: "send_email" }, + toolArguments: { to: "customer@example.com" }, + status: "pending", + }); + }); + + afterAll(async () => { + await db.db + .delete(schema.approval) + .where(eq(schema.approval.id, approvalId)); + await db.db + .delete(schema.workflowRun) + .where(eq(schema.workflowRun.id, runId)); + await db.db + .delete(schema.workflowDefinition) + .where(eq(schema.workflowDefinition.id, definitionId)); + await db.db.delete(schema.tenant).where(eq(schema.tenant.id, tenantId)); + await db.db + .delete(schema.tenant) + .where(eq(schema.tenant.id, otherTenantId)); + await db.close(); + }); + + test("hydrateNeedsYou resolves the agent and bench names off the approval's own foreign keys", async () => { + const row = await db.db.query.approval.findFirst({ + where: eq(schema.approval.id, approvalId), + }); + if (row === undefined) throw new Error("fixture approval missing"); + + const [item] = await hydrateNeedsYou(db.db, [parseApprovalRow(row)]); + + expect(item).toBeDefined(); + expect(item?.agentName).toBe("Outreach Composer"); + expect(item?.benchName).toBe("Growth Team Bench"); + expect(item?.headline).toBe("send_email"); + // The UI floor: nothing on the view model may be the raw id a person + // would have to decode to understand what they're looking at. + expect(JSON.stringify(item)).not.toContain(runId); + expect(JSON.stringify(item)).not.toContain(tenantId); + expect(JSON.stringify(item)).not.toContain(definitionId); + }); + + function mountedApp(principal: PrincipalRow) { + const app = new Hono(); + app.use("*", async (c: Context, next: Next) => { + c.set("tenant", tenantRow); + c.set("principal", principal); + c.set("user", null); + c.set("session", null); + await next(); + }); + app.route( + "/", + createNeedsYouRoutes({ + db: db.db, + grantStore: createInMemoryGrantStore([ + { + id: generateId("grant"), + resource: "approval:*", + action: "resolve", + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: approverPrincipalId, + }, + ]), + conditionRegistry: {}, + }), + ); + return app; + } + + test("a principal holding the approval:* grant sees the pending item with resolved names", async () => { + const app = mountedApp({ + id: approverPrincipalId, + tenantId, + kind: "user", + refId: "usr_approver", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const response = await app.request("/"); + expect(response.status).toBe(200); + const body = (await response.json()) as { items: unknown[] }; + expect(body.items).toHaveLength(1); + expect(body.items[0]).toMatchObject({ + agentName: "Outreach Composer", + benchName: "Growth Team Bench", + headline: "send_email", + }); + }); + + test("a principal with no approval grant is refused server-side, not just hidden in the UI", async () => { + const app = mountedApp({ + id: strangerPrincipalId, + tenantId, + kind: "user", + refId: "usr_stranger", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const response = await app.request("/"); + expect(response.status).toBe(403); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("forbidden"); + }); +}); diff --git a/packages/approvals/tsconfig.json b/packages/approvals/tsconfig.json new file mode 100644 index 000000000..e956ddd88 --- /dev/null +++ b/packages/approvals/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["src", "test"] +}