Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 10 additions & 41 deletions apps/web/src/pages/worker-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -364,9 +331,11 @@ export function WorkerRoute({ agentId }: { readonly agentId: string }) {
/>
<div className="min-w-0 flex-1">
<h1 className="text-[24px] font-extrabold">{agent.name}</h1>
{isMyraAgent(agent) ? (
<p className="mt-0.5 text-[14px] text-(--ink-2)">Your assistant</p>
) : null}
<WorkerRole
tenantId={selectedTenantId}
agent={agent}
className="mt-0.5 text-[14px] text-(--ink-2)"
/>
<p className="mt-1 flex items-center gap-2 text-[14px] text-(--ink-2)">
<StatusPill tone={status.tone} />
{status.text}
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/pages/workers-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -46,6 +47,18 @@ const PILL_LABEL: Record<WorkerTone, string> = {
idle: "Idle",
};

export function WorkerRole({
tenantId,
agent,
className,
}: {
readonly tenantId: string;
readonly agent: ChatAgent;
readonly className?: string;
}) {
return <p className={className}>{useWorkerRole(tenantId, agent)}</p>;
}

export function StatusPill({ tone }: { readonly tone: WorkerTone }) {
return (
<span
Expand Down Expand Up @@ -161,7 +174,14 @@ export function WorkersRosterList({
/>
<span className="flex min-w-0 items-center gap-3">
<WorkbenchAvatar kind="worker" name={agent.name} size={40} status={status.tone} />
<span className="min-w-0 text-[14.5px] font-extrabold">{agent.name}</span>
<span className="min-w-0">
<span className="block text-[14.5px] font-extrabold">{agent.name}</span>
<WorkerRole
tenantId={tenantId}
agent={agent}
className="truncate text-[12.5px] text-(--ink-3)"
/>
</span>
</span>
<span className="flex min-w-0 items-center gap-2.5 text-[13.5px]">
<StatusPill tone={status.tone} />
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/worker-role-query.ts
Original file line number Diff line number Diff line change
@@ -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 ?? [],
});
}
34 changes: 34 additions & 0 deletions apps/web/src/worker-role.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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;
}
40 changes: 40 additions & 0 deletions apps/web/src/worker-tool-grants.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading