diff --git a/apps/web/src/pages/worker-page.tsx b/apps/web/src/pages/worker-page.tsx index 48e00c835..03b014ad4 100644 --- a/apps/web/src/pages/worker-page.tsx +++ b/apps/web/src/pages/worker-page.tsx @@ -12,16 +12,11 @@ import { isMyraAgent, listChatAgents, type ChatAgent } from "@/chat/threads-api" import { ChatCircle } from "@/lib/icons"; import { QueryView, describeApiError } from "@/lib/api-query"; import { listTopLevelRuns } from "../agents-api"; -import { - createGrant, - listGrants, - listPrincipals, - revokeGrant, - type Grant, -} from "../settings/tenancy-api"; +import { createGrant, revokeGrant, type Grant } from "../settings/tenancy-api"; import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy"; import { readAgentMcpHandles, readAgentSource } from "../agent-source-read"; -import { applyGrantSnapshot, readGrantSnapshot, saveGrantSnapshot } from "../worker-grants-carry"; +import { readGrantSnapshot, saveGrantSnapshot } from "../worker-grants-carry"; +import { TOOL_PREFIX, readWorkerToolGrants, workerToolGrantsKey } from "../worker-tool-grants"; import { useBench } from "../bench-context"; import { Link } from "../navigation"; import { tenantKeys } from "../query-client"; @@ -30,7 +25,7 @@ import { StageTopBar } from "../shell/stage-top-bar"; import { WORKERS_PATH_PREFIX } from "../path-ids"; import { workbenchPath } from "../workbench-path"; import { useWorkerBenches } from "../worker-benches"; -import { StatusPill, workerStatus } from "./workers-page"; +import { StatusPill, WorkerRole, workerStatus } from "./workers-page"; function sourceKey(tenantId: string, agentId: string) { return [...tenantKeys.agents(tenantId), "source", agentId] as const; @@ -139,34 +134,6 @@ const MODES: readonly { readonly effect: GrantEffect; readonly label: string }[] { effect: "deny", label: "Deny" }, ]; -const TOOL_PREFIX = "tool:"; - -/** The worker's live run principal: runtime grants belong to it, and it only - * exists once the worker has been triggered. */ -async function readWorkerToolGrants(tenantId: string, agent: ChatAgent) { - if (agent.liveAddress === null) return null; - const [runs, principals] = await Promise.all([ - listTopLevelRuns(tenantId), - listPrincipals(tenantId), - ]); - const run = runs.find((candidate) => candidate.address === agent.liveAddress); - const principal = principals.find( - (candidate) => candidate.kind === "workflow" && candidate.refId === run?.id, - ); - if (principal === undefined) return null; - const read = async () => { - const grants = await listGrants(tenantId, { principalId: principal.id }); - return grants - .filter((grant) => grant.resource.startsWith(TOOL_PREFIX) && grant.action === "invoke") - .sort((a, b) => a.resource.localeCompare(b.resource)); - }; - let tools = await read(); - if (await applyGrantSnapshot(tenantId, agent.assetName, principal.id, tools)) { - tools = await read(); - } - return { principalId: principal.id, tools }; -} - function PermissionsCard({ tenantId, agent, @@ -175,7 +142,7 @@ function PermissionsCard({ readonly agent: ChatAgent; }) { const queryClient = useQueryClient(); - const key = [...tenantKeys.grants(tenantId), "worker", agent.id] as const; + const key = workerToolGrantsKey(tenantId, agent.id); // While a redeploy's permissions are waiting for the new run to start, // keep looking for its principal. const carrying = readGrantSnapshot(tenantId, agent.assetName) !== null; @@ -364,9 +331,11 @@ export function WorkerRoute({ agentId }: { readonly agentId: string }) { />

{agent.name}

- {isMyraAgent(agent) ? ( -

Your assistant

- ) : null} +

{status.text} diff --git a/apps/web/src/pages/workers-page.tsx b/apps/web/src/pages/workers-page.tsx index 381cb0829..d9c83c4b9 100644 --- a/apps/web/src/pages/workers-page.tsx +++ b/apps/web/src/pages/workers-page.tsx @@ -20,6 +20,7 @@ import { PageLayout } from "../shell/page-layout"; import { StageTopBar } from "../shell/stage-top-bar"; import { CreateAgentPanel } from "./create-agent-panel"; import { WORKERS_PATH_PREFIX } from "../path-ids"; +import { useWorkerRole } from "../worker-role-query"; export type WorkerTone = "working" | "ready" | "idle"; @@ -46,6 +47,18 @@ const PILL_LABEL: Record = { idle: "Idle", }; +export function WorkerRole({ + tenantId, + agent, + className, +}: { + readonly tenantId: string; + readonly agent: ChatAgent; + readonly className?: string; +}) { + return

{useWorkerRole(tenantId, agent)}

; +} + export function StatusPill({ tone }: { readonly tone: WorkerTone }) { return ( - {agent.name} + + {agent.name} + + diff --git a/apps/web/src/worker-role-query.ts b/apps/web/src/worker-role-query.ts new file mode 100644 index 000000000..2a7a776d4 --- /dev/null +++ b/apps/web/src/worker-role-query.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import { reportError } from "@corbits/error-sink"; + +import { isMyraAgent, type ChatAgent } from "./chat/threads-api"; +import { readAgentMcpHandles } from "./agent-source-read"; +import { tenantKeys } from "./query-client"; +import { workerRole } from "./worker-role"; +import { TOOL_PREFIX, readWorkerToolGrants, workerToolGrantsKey } from "./worker-tool-grants"; + +/** The role line for a worker, from its bound MCP servers and its run's + * `tool:*` grants. Until both land it reads as no tools yet. */ +export function useWorkerRole(tenantId: string, agent: ChatAgent): string { + const isMyra = isMyraAgent(agent); + const grants = useQuery({ + queryKey: workerToolGrantsKey(tenantId, agent.id), + queryFn: () => readWorkerToolGrants(tenantId, agent), + enabled: !isMyra, + }); + const handles = useQuery({ + queryKey: [...tenantKeys.agents(tenantId), "mcp-handles", agent.id], + queryFn: async () => { + try { + return await readAgentMcpHandles(tenantId, agent.id, agent.assetName); + } catch (error) { + reportError(error, { operation: "worker_role_mcp_handles_read" }); + return []; + } + }, + enabled: !isMyra, + }); + return workerRole({ + isMyra, + tools: (grants.data?.tools ?? []).map((grant) => grant.resource.slice(TOOL_PREFIX.length)), + mcpServers: handles.data ?? [], + }); +} diff --git a/apps/web/src/worker-role.ts b/apps/web/src/worker-role.ts new file mode 100644 index 000000000..886763125 --- /dev/null +++ b/apps/web/src/worker-role.ts @@ -0,0 +1,34 @@ +// A worker's role line: the stock definition has no role field, so it is +// derived from what the worker can reach. + +const MAX_ITEMS = 3; + +const TOOL_FAMILIES: readonly (readonly [RegExp, string])[] = [ + [/^mail_/, "Mail"], + [/^(posix|fs|shell)(_|$)/, "Files & shell"], +]; + +function titleCase(handle: string): string { + return handle + .split(/[-_\s]+/) + .filter((word) => word !== "") + .map((word) => word[0]?.toUpperCase() + word.slice(1)) + .join(" "); +} + +export function workerRole(input: { + readonly tools: readonly string[]; + readonly mcpServers: readonly string[]; + readonly isMyra: boolean; +}): string { + if (input.isMyra) return "Assistant"; + const items = new Set(); + for (const [pattern, label] of TOOL_FAMILIES) { + if (input.tools.some((tool) => pattern.test(tool))) items.add(label); + } + for (const server of input.mcpServers) items.add(titleCase(server)); + const all = [...items]; + if (all.length === 0) return "No tools yet"; + const shown = all.slice(0, MAX_ITEMS).join(" ยท "); + return all.length > MAX_ITEMS ? `${shown} +${all.length - MAX_ITEMS}` : shown; +} diff --git a/apps/web/src/worker-tool-grants.ts b/apps/web/src/worker-tool-grants.ts new file mode 100644 index 000000000..0a7a6d67b --- /dev/null +++ b/apps/web/src/worker-tool-grants.ts @@ -0,0 +1,40 @@ +// The worker's live run principal grants, shared by the worker page and the +// role line. + +import { listTopLevelRuns } from "./agents-api"; +import type { ChatAgent } from "./chat/threads-api"; +import { tenantKeys } from "./query-client"; +import { listGrants, listPrincipals } from "./settings/tenancy-api"; +import { applyGrantSnapshot } from "./worker-grants-carry"; + +export function workerToolGrantsKey(tenantId: string, agentId: string) { + return [...tenantKeys.grants(tenantId), "worker", agentId] as const; +} + +export const TOOL_PREFIX = "tool:"; + +/** The worker's live run principal: runtime grants belong to it, and it only + * exists once the worker has been triggered. */ +export async function readWorkerToolGrants(tenantId: string, agent: ChatAgent) { + if (agent.liveAddress === null) return null; + const [runs, principals] = await Promise.all([ + listTopLevelRuns(tenantId), + listPrincipals(tenantId), + ]); + const run = runs.find((candidate) => candidate.address === agent.liveAddress); + const principal = principals.find( + (candidate) => candidate.kind === "workflow" && candidate.refId === run?.id, + ); + if (principal === undefined) return null; + const read = async () => { + const grants = await listGrants(tenantId, { principalId: principal.id }); + return grants + .filter((grant) => grant.resource.startsWith(TOOL_PREFIX) && grant.action === "invoke") + .sort((a, b) => a.resource.localeCompare(b.resource)); + }; + let tools = await read(); + if (await applyGrantSnapshot(tenantId, agent.assetName, principal.id, tools)) { + tools = await read(); + } + return { principalId: principal.id, tools }; +}