From ffc225bace9ee2c09907624e13a37532739f7102 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 21:55:51 -0700 Subject: [PATCH] feat(web): Agents and Skills pages read stock deployments; create agent deploys a workflow asset (CL-8433) --- apps/web/src/agent-deploy.ts | 258 +++++++ apps/web/src/agents-api.ts | 365 +++------- apps/web/src/myra-workbench.ts | 2 +- apps/web/src/pages/agent-detail-page.tsx | 743 ++++++-------------- apps/web/src/pages/agent-skills-picker.tsx | 130 ---- apps/web/src/pages/agents-page.tsx | 330 +-------- apps/web/src/pages/create-agent-panel.tsx | 450 ++---------- apps/web/src/pages/new-workbench-picker.tsx | 28 +- 8 files changed, 656 insertions(+), 1650 deletions(-) create mode 100644 apps/web/src/agent-deploy.ts delete mode 100644 apps/web/src/pages/agent-skills-picker.tsx diff --git a/apps/web/src/agent-deploy.ts b/apps/web/src/agent-deploy.ts new file mode 100644 index 000000000..d0ca8e990 --- /dev/null +++ b/apps/web/src/agent-deploy.ts @@ -0,0 +1,258 @@ +// Deploys a hand-authored agent the same way Myra deploys herself +// (`myra-deploy.ts`): a `workflow`-kind asset holding a rendered source +// tree, pushed over the stock git smart-HTTP route, then deployed through +// the stock `POST /workflows/deployments`. Generalized over {name, +// displayName, systemPrompt} so the create-agent panel can deploy any +// agent through the one path the platform actually backs. +import { renderWorkflowSourceTree } from "@corbits/workflows/client"; +import { type } from "arktype"; +import { WorkflowDeploymentResponse } from "@intx/types"; + +import { resolveExistingOffering } from "./onboarding/provider-connect-step"; +import { isValidSlug, slugify } from "@/lib/slug"; + +export class AgentDeployError extends Error {} + +const AssetCreatedShape = type({ id: "string" }); +const AssetListShape = type({ id: "string", name: "string" }).array(); +const GitTokenMintShape = type({ id: "string", secret: "string" }); +const TenantDomainShape = type({ domain: "string" }); + +const PUSH_TOKEN_LIFETIME_MS = 10 * 60 * 1000; +const AGENT_TURN_TIMEOUT_MS = 2 * 60 * 1000; + +async function readErrorBody(response: Response): Promise { + const body: unknown = await response.json().catch(() => undefined); + const envelope = type({ + error: { code: "string", userMessage: "string", refId: "string" }, + })(body); + return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage; +} + +/** The `workflow`-kind asset a given agent's source pushes into, derived + * from its name — idempotent create-or-find, mirroring `ensureMyraSourceAsset` + * but keyed on a caller-supplied name rather than Myra's fixed one. */ +export async function ensureAgentSourceAsset( + tenantId: string, + assetName: string, + displayName: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const created = await fetchImpl(`/api/tenants/${encodeURIComponent(tenantId)}/assets`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind: "workflow", name: assetName, displayName }), + }); + if (created.status === 201) { + const parsed = AssetCreatedShape(await created.json()); + if (parsed instanceof type.errors) { + throw new AgentDeployError( + `this agent's source came back an unexpected shape: ${parsed.summary}`, + ); + } + return parsed.id; + } + if (created.status !== 409) { + throw new AgentDeployError( + `preparing this agent's source failed: ${await readErrorBody(created)}`, + ); + } + const listed = await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/assets?kind=workflow&inherited=false`, + ); + if (!listed.ok) { + throw new AgentDeployError( + `checking this workbench's agents failed: ${await readErrorBody(listed)}`, + ); + } + const parsed = AssetListShape(await listed.json()); + if (parsed instanceof type.errors) { + throw new AgentDeployError( + `this workbench's agent list came back an unexpected shape: ${parsed.summary}`, + ); + } + const existing = parsed.find((asset) => asset.name === assetName); + if (existing === undefined) { + throw new AgentDeployError( + "this agent's source reported a name conflict but is not listed on this workbench", + ); + } + return existing.id; +} + +/** The exact `WorkflowDefinition` JSON for a single-step, mail-triggered, + * unbounded-turn agent — the same shape `buildMyraDefinitionJson` produces, + * generalized over the caller's own name and system prompt. */ +export function buildAgentDefinitionJson(args: { + slug: string; + systemPrompt: string; + triggerAddress: string; + declaredSources: readonly { readonly provider: string; readonly model: string }[]; +}): unknown { + const stepId = "run"; + return { + id: args.slug, + triggers: [{ type: "mail", to: args.triggerAddress }], + steps: { + [stepId]: { + kind: "step", + id: stepId, + agent: { + id: stepId, + description: `The "${args.slug}" agent`, + systemPrompt: args.systemPrompt, + toolFactories: [], + capabilities: [], + inference: { sources: args.declaredSources.map((source) => ({ ...source })) }, + toolPackagePins: [], + }, + drainBehavior: "wait", + timeout: AGENT_TURN_TIMEOUT_MS, + triggers: "unbounded", + input: { from: "trigger.payload" }, + }, + }, + stepOrder: [stepId], + }; +} + +async function withPushToken( + tenantId: string, + assetId: string, + fetchImpl: typeof fetch, + push: (token: string) => Promise, +): Promise { + const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`; + const minted = await fetchImpl(tokensPath, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: `agent-deploy-${crypto.randomUUID()}`, + resource: `asset:${assetId}`, + refPattern: "refs/heads/main", + actions: ["can_read", "can_push"], + expiresAt: new Date(Date.now() + PUSH_TOKEN_LIFETIME_MS).toISOString(), + }), + }); + if (!minted.ok) { + throw new AgentDeployError(`minting a push token failed: ${await readErrorBody(minted)}`); + } + const token = GitTokenMintShape(await minted.json()); + if (token instanceof type.errors) { + throw new AgentDeployError(`the push token came back an unexpected shape: ${token.summary}`); + } + try { + return await push(token.secret); + } finally { + await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" }); + } +} + +/** Renders this agent's built definition as a source tree and pushes it to + * its asset's `main`. Returns the commit sha the deploy pins to. */ +export async function pushAgentSource( + tenantId: string, + assetId: string, + assetName: string, + packageName: string, + workflowJson: unknown, + fetchImpl: typeof fetch = fetch, +): Promise { + const tree = renderWorkflowSourceTree({ + packageName, + workflowJson: JSON.stringify(workflowJson), + }); + const url = new URL( + `/api/tenants/${encodeURIComponent(tenantId)}/assets/workflow/${assetName}.git`, + globalThis.location.origin, + ).toString(); + const { pushSourceTree } = await import("./git-push"); + return withPushToken(tenantId, assetId, fetchImpl, (token) => + pushSourceTree({ url, token, tree, message: `Publish ${assetName}'s definition` }), + ); +} + +export type NewAgentInput = { + readonly name: string; + readonly systemPrompt: string; +}; + +export type DeployedAgent = typeof WorkflowDeploymentResponse.infer; + +/** + * Deploys a hand-authored agent: ensures its source asset, pushes its + * rendered definition, resolves the tenant's existing inference offering + * (the same one Myra's own deploy resolves through), and deploys through + * the stock `POST /workflows/deployments`. Fails closed when no provider is + * connected yet — there is no offering to deploy against. + */ +export async function deployAgentSource( + args: { readonly tenantId: string; readonly input: NewAgentInput }, + fetchImpl: typeof fetch = fetch, +): Promise { + const name = args.input.name.trim(); + if (name === "") throw new AgentDeployError("an agent needs a name"); + const systemPrompt = args.input.systemPrompt.trim(); + if (systemPrompt === "") throw new AgentDeployError("an agent needs a system prompt"); + + const slug = slugify(name); + if (!isValidSlug(slug)) { + throw new AgentDeployError("this name doesn't produce a usable agent address"); + } + const assetName = `agent-${slug}-source`; + const packageName = `@workbench-agent/${slug}`; + + const tenantResponse = await fetchImpl(`/api/tenants/${encodeURIComponent(args.tenantId)}`); + if (!tenantResponse.ok) { + throw new AgentDeployError( + `resolving this workbench's domain failed: ${await readErrorBody(tenantResponse)}`, + ); + } + const tenant = TenantDomainShape(await tenantResponse.json()); + if (tenant instanceof type.errors) { + throw new AgentDeployError(`this workbench came back an unexpected shape: ${tenant.summary}`); + } + + const offering = await resolveExistingOffering(args.tenantId); + if (offering === null) { + throw new AgentDeployError("connect a model provider in Settings before deploying an agent"); + } + + const assetId = await ensureAgentSourceAsset(args.tenantId, assetName, name, fetchImpl); + const workflowJson = buildAgentDefinitionJson({ + slug, + systemPrompt, + triggerAddress: `${slug}@${tenant.domain}`, + declaredSources: offering.declaredSources, + }); + const commitSha = await pushAgentSource( + args.tenantId, + assetId, + assetName, + packageName, + workflowJson, + fetchImpl, + ); + + const deployed = await fetchImpl( + `/api/tenants/${encodeURIComponent(args.tenantId)}/workflows/deployments`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + source: { kind: "asset", assetId, package: { format: "source", commitSha } }, + entry: "./workflow.js", + sourceOfferingIds: offering.sourceOfferingIds, + defaultSourceOfferingId: offering.defaultSourceOfferingId, + }), + }, + ); + if (!deployed.ok) { + throw new AgentDeployError(`deploying this agent failed: ${await readErrorBody(deployed)}`); + } + const parsed = WorkflowDeploymentResponse(await deployed.json()); + if (parsed instanceof type.errors) { + throw new AgentDeployError(`this deployment came back an unexpected shape: ${parsed.summary}`); + } + return parsed; +} diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index 8e468ef1c..263bc3eb4 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -9,18 +9,20 @@ import { ModelResponse, + RunApprovalsResponse, WorkflowDefinitionResponse, + WorkflowRunHealth, WorkflowRunResponse, paginatedSchema, } from "@intx/types"; import { type } from "arktype"; import type { ArkErrors } from "arktype"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { APIQuery } from "@/lib/api-query"; import { ApiQueryError, UnauthenticatedError, toAPIQuery } from "@/lib/api-query"; import { isChatPickerModelName } from "./settings/inference/model-capability"; -import { parseErrorEnvelope } from "@corbits/error-sink"; +import { deployAgentSource, type DeployedAgent, type NewAgentInput } from "./agent-deploy"; import { tenantKeys } from "./query-client"; export type AgentDefinition = typeof WorkflowDefinitionResponse.infer; @@ -63,43 +65,6 @@ async function getJSON(path: string, schema: Validator): Promise { return parsed; } -async function postJSON( - path: string, - schema: Validator, - body: unknown, - method: "POST" | "PUT" | "DELETE" = "POST", -): Promise { - let response: Response; - try { - response = await fetch(path, { - method, - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - } catch (cause) { - throw new ApiQueryError( - cause instanceof Error ? cause.message : String(cause), - undefined, - path, - ); - } - const json: unknown = await response.json().catch(() => undefined); - if (!response.ok) { - const envelope = parseErrorEnvelope(json); - throw new ApiQueryError( - envelope?.error.userMessage ?? `The server answered ${response.status}.`, - response.status, - path, - envelope?.error.refId, - ); - } - const parsed = schema(json); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path); - } - return parsed; -} - export function listAgentDefinitions(tenantId: string): Promise { return getJSON( `/api/tenants/${tenantId}/workflows/definitions?limit=${PAGE_LIMIT}`, @@ -145,217 +110,52 @@ export function listCatalogModels(tenantId: string): Promise { - return postJSON( - `/api/tenants/${tenantId}/planner/agent-definitions/draft`, - AgentDefinitionDraftResponse, - input, - ).then((body) => body.draft); -} - -export type CreateAgentDefinitionInput = { - readonly name: string; - readonly handle: string; - readonly description?: string; - readonly systemPrompt: string; - readonly model?: string; - readonly skills?: readonly string[]; - /** Tool packages to pin by name (no version — the create route - * resolves each to `*`). Used by a template-driven create, never by - * the hand-authored create form, which has no field for it. */ - readonly toolPackagePins?: readonly string[]; -}; - -const CreatedAgentDefinition = WorkflowDefinitionResponse.and({ - skills: "string[]", -}); - -export function createAgentDefinition( - tenantId: string, - input: CreateAgentDefinitionInput, -): Promise { - return postJSON(`/api/tenants/${tenantId}/agent-definitions`, CreatedAgentDefinition, input); -} - -const AgentCapabilitiesResponse = type({ - name: "string", - "model?": "string", -}); -export type AgentCapabilities = typeof AgentCapabilitiesResponse.infer; - -/** `GET /api/tenants/:t/agent-definitions/:id` — the same route - * `@/chat`'s per-workbench Agents section reads for its model - * picker. Fetched lazily, per definition, only once its row is expanded on - * the Agents roster — the paginated definitions list itself carries no - * model field. */ -export function getAgentCapabilities( - tenantId: string, - definitionId: string, -): Promise { - return getJSON( - `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}`, - AgentCapabilitiesResponse, - ); -} - -/** `GET /agent-definitions/by-name/:slug` — one definition resolved by its - * immutable slug, server-side. A slug-addressed page reads this instead of - * scanning the paginated definitions listing, so an agent past that - * listing's ceiling still answers on its own URL. */ -export function getAgentDefinitionBySlug(tenantId: string, slug: string): Promise { +/** A single top-level run's own detail — `GET /workflows/runs/:runId`. For + * a deployment's anchor run (which is what `AgentInstance` already lists), + * this is the same record; fetched again here only right after a fresh + * deploy, before the roster's own listing has picked it up. */ +export function getAgentRun(tenantId: string, runId: string): Promise { return getJSON( - `/api/tenants/${tenantId}/agent-definitions/by-name/${encodeURIComponent(slug)}`, - WorkflowDefinitionResponse, + `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}`, + WorkflowRunResponse, ); } -const AgentDefinitionDetailResponse = type({ - name: "string", - systemPrompt: "string", - "model?": "string", - skills: "string[]", -}); -export type AgentDefinitionDetail = typeof AgentDefinitionDetailResponse.infer; +export type AgentRunHealth = typeof WorkflowRunHealth.infer; -/** Everything the agent detail page edits, read from the one route that - * owns a definition's authored state (`GET /agent-definitions/:id`): its - * display name, its system prompt, its pinned skills, and the model it - * resolves against. `name` here is the display name the definition's row - * carries, never its immutable slug. */ -export function getAgentDefinitionDetail( - tenantId: string, - definitionId: string, -): Promise { +/** `GET /workflows/runs/:runId/health` — liveness/readiness for a live run. */ +export function getAgentRunHealth(tenantId: string, runId: string): Promise { return getJSON( - `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}`, - AgentDefinitionDetailResponse, - ); -} - -/** Replaces a definition's display name and system prompt in one write — - * the same route the per-workbench Assistant editor saves through, never - * a second write path of this page's own. */ -export function updateAgentInstructions( - tenantId: string, - definitionId: string, - input: { readonly name: string; readonly systemPrompt: string }, -): Promise<{ readonly name: string; readonly systemPrompt: string }> { - return postJSON( - `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}`, - type({ name: "string", systemPrompt: "string" }), - input, - "PUT", + `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/health`, + WorkflowRunHealth, ); } -const AgentCapabilitiesWriteResponse = type({ - skills: "string[]", - "model?": "string", -}); - -/** Sets the model a definition resolves against, through the guided - * capability-add route — which re-checks the name against the tenant's - * live catalog, so a model this bench cannot actually reach is refused - * rather than written. */ -export function setAgentModel( - tenantId: string, - definitionId: string, - canonicalName: string, -): Promise<{ readonly model?: string }> { - return postJSON( - `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}/capabilities`, - AgentCapabilitiesWriteResponse, - { kind: "model", canonicalName }, - ); -} +const RunEvent = type({ seq: "number", type: "string", body: "Record" }); +export type AgentRunEvent = typeof RunEvent.infer; +const RunEventsResponse = type({ runId: "string", events: RunEvent.array() }); -/** Un-pins a definition's model, returning it to the bench default. Its own - * verb rather than `setAgentModel("")`: "no model" is not a name the - * capability route's inventory check could ever accept. */ -export function clearAgentModel( +/** `GET /workflows/runs/:runId/events` — the run's committed, seq-ordered + * event log. */ +export function getAgentRunEvents( tenantId: string, - definitionId: string, -): Promise<{ readonly model?: string }> { - return postJSON( - `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}/capabilities/model`, - AgentCapabilitiesWriteResponse, - {}, - "DELETE", - ); -} - -/** Archives (`stopped`) or restores (`deployed`) a definition. Nothing is - * deleted either way — an archived agent keeps its row, its asset, and its - * history, and simply stops appearing anywhere a person can launch it. */ -export function setAgentDefinitionStatus( - tenantId: string, - definitionId: string, - status: "deployed" | "stopped", -): Promise<{ readonly status: string }> { - return postJSON( - `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}/status`, - type({ id: "string", status: "string" }), - { status }, - "PUT", - ); + runId: string, +): Promise { + return getJSON( + `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/events`, + RunEventsResponse, + ).then((page) => page.events); } -const DefinitionSkillsMap = type({ skills: { "[string]": "string[]" } }); +export type AgentRunApprovals = typeof RunApprovalsResponse.infer; -/** Every attached-skill list for the given definitions, keyed by definition - * id. Call sites treat failure as its own outcome (`skillsError`) rather than - * coercing to `{}` — empty attachments and a failed read are different. */ -export function listAgentSkills( - tenantId: string, - definitionIds: readonly string[], -): Promise> { - if (definitionIds.length === 0) return Promise.resolve({}); - const ids = encodeURIComponent(definitionIds.join(",")); +/** `GET /workflows/runs/:runId/approvals` — the run's approval decisions, + * newest first. */ +export function getAgentRunApprovals(tenantId: string, runId: string): Promise { return getJSON( - `/api/tenants/${tenantId}/agent-definitions/skills?ids=${ids}`, - DefinitionSkillsMap, - ).then((page) => page.skills); -} - -/** Replaces one definition's attached skills wholesale — an empty array - * detaches every skill, never a partial patch. */ -export function updateAgentSkills( - tenantId: string, - definitionId: string, - skills: readonly string[], -): Promise { - return postJSON( - `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}/skills`, - type({ skills: "string[]" }), - { skills }, - "PUT", - ).then((body) => body.skills); + `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/approvals`, + RunApprovalsResponse, + ); } export type AgentDirectoryData = { @@ -363,33 +163,19 @@ export type AgentDirectoryData = { readonly definitions: readonly AgentDefinition[]; readonly instances: readonly AgentInstance[]; readonly models: readonly CatalogModel[]; - /** Attached skills per definition id. Missing entries read as "none". */ - readonly definitionSkills: Record; /** Set when the model catalog failed independently; definitions and * instances still load so the page stays usable. */ readonly modelsError?: string; - /** Set when the attached-skills batch failed independently; definitions - * and instances still load. Distinct from an empty `definitionSkills` - * map — failure must never read as "no skills attached". */ - readonly skillsError?: string; }; type ModelsOutcome = | { readonly ok: true; readonly models: readonly CatalogModel[] } | { readonly ok: false; readonly message: string }; -type SkillsOutcome = - | { - readonly ok: true; - readonly definitionSkills: Record; - } - | { readonly ok: false; readonly message: string }; - /** * Loads a bench's agent directory. Definitions and instances are required; - * the model catalog and each definition's attached skills are best-effort - * so either failing alone never blanks the page. Failures surface as - * `modelsError` / `skillsError` rather than silent empty collections. + * the model catalog is best-effort so its failure alone never blanks the + * page — surfaced as `modelsError` rather than a silent empty catalog. * `instances` comes from `listTopLevelRuns`, which already excludes every * non-top-level run (workbench host, invited agent) server-side — the * native `GET /workflows/runs` listing's own predicate — so this page @@ -408,34 +194,21 @@ export async function loadAgentDirectory(tenantId: string): Promise definition.id), - ).then( - (definitionSkills): SkillsOutcome => ({ ok: true, definitionSkills }), - (cause: unknown): SkillsOutcome => ({ - ok: false, - message: cause instanceof Error ? cause.message : String(cause), - }), - ); - return { tenantId, definitions, instances, models: modelsOutcome.ok ? modelsOutcome.models : [], - definitionSkills: skillsOutcome.ok ? skillsOutcome.definitionSkills : {}, ...(modelsOutcome.ok ? {} : { modelsError: modelsOutcome.message }), - ...(skillsOutcome.ok ? {} : { skillsError: skillsOutcome.message }), }; } /** * Loads a bench's full agent directory. One query owns definitions + - * instances + models + skills (models and skills are best-effort inside - * `loadAgentDirectory`, surfacing `modelsError` / `skillsError`) so the - * page keeps a single loading/error envelope. Pass no reloadKey — - * invalidate `tenantKeys.agentDirectory(tenantId)` after create. + * instances + models (models are best-effort inside `loadAgentDirectory`, + * surfacing `modelsError`) so the page keeps a single loading/error + * envelope. Pass no reloadKey — invalidate `tenantKeys.agentDirectory(tenantId)` + * after create. */ export function useAgentDirectory(tenantId: string | undefined): APIQuery { const result = useQuery({ @@ -460,3 +233,59 @@ export function useAgentDirectory(tenantId: string | undefined): APIQuery { + const result = useQuery({ + queryKey: ["agent-run-health", tenantId, runId] as const, + enabled: tenantId !== null && runId !== null, + queryFn: () => getAgentRunHealth(tenantId as string, runId as string), + }); + return toAPIQuery(result); +} + +/** A selected agent run's committed event log. */ +export function useAgentRunEvents( + tenantId: string | null, + runId: string | null, +): APIQuery { + const result = useQuery({ + queryKey: ["agent-run-events", tenantId, runId] as const, + enabled: tenantId !== null && runId !== null, + queryFn: () => getAgentRunEvents(tenantId as string, runId as string), + }); + return toAPIQuery(result); +} + +/** A selected agent run's approval decisions, newest first. */ +export function useAgentRunApprovals( + tenantId: string | null, + runId: string | null, +): APIQuery { + const result = useQuery({ + queryKey: ["agent-run-approvals", tenantId, runId] as const, + enabled: tenantId !== null && runId !== null, + queryFn: () => getAgentRunApprovals(tenantId as string, runId as string), + }); + return toAPIQuery(result); +} + +/** + * Deploys a hand-authored agent through the stock workflow-deploy path + * (`agent-deploy.ts`), then invalidates the bench's agent directory so the + * roster picks up the new deployment without a manual refetch. + */ +export function useDeployAgentMutation(tenantId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: NewAgentInput): Promise => + deployAgentSource({ tenantId, input }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: tenantKeys.agentDirectory(tenantId) }); + }, + }); +} diff --git a/apps/web/src/myra-workbench.ts b/apps/web/src/myra-workbench.ts index 7e7118ebe..dd8b706c6 100644 --- a/apps/web/src/myra-workbench.ts +++ b/apps/web/src/myra-workbench.ts @@ -1,7 +1,7 @@ // Default Myra chat: the product land surface. Composition only — the // find-or-create logic itself is `@/chat`'s generic // `createDefaultAgentWorkbench`; this file's job is to name Myra as the -// configured agent and wire it to this app's agent-definitions fetch. +// configured agent and wire it to this app's workflow-definitions fetch. import { createDefaultAgentWorkbench, findDefinitionByAssetName } from "@/chat"; import { WORKFLOW_CATALOG } from "@corbits/workflows/catalog"; diff --git a/apps/web/src/pages/agent-detail-page.tsx b/apps/web/src/pages/agent-detail-page.tsx index 290e96968..e6c2be624 100644 --- a/apps/web/src/pages/agent-detail-page.tsx +++ b/apps/web/src/pages/agent-detail-page.tsx @@ -1,91 +1,43 @@ -// The agent's own page, addressed by its immutable slug — -// `/agents/`. Everything a person can author about an agent lives -// here: its display name, the model it resolves against, the system prompt -// it follows on every turn, the skills it has pinned, and the runs it has -// produced. The roster's quick-peek panel stays where it is; this is the -// full page a person lands on when quick-peek isn't enough (DESIGN.md, -// "Detail Pages"). -// -// Every write goes through a mutation `@corbits/agent-directory` owns, via -// `../agents-api.ts` — this page invents no write path of its own: -// -// display name + system prompt PUT /agent-definitions/:id -// default model POST /agent-definitions/:id/capabilities -// clearing the model DELETE /agent-definitions/:id/capabilities/model -// pinned skills PUT /agent-definitions/:id/skills -// archive / restore PUT /agent-definitions/:id/status -// duplicate POST /agent-definitions -// -// One Save writes every dirty part and nothing else — an untouched field is -// never rewritten. Those parts are separate requests, so a Save can land -// partway: the page reports exactly which parts saved and which did not, -// and reloads either way, rather than claiming a clean failure over a -// half-applied change. Folding the three into one transactional route is -// the real fix and is deferred (see the PR). -// -// Two fields a person might expect are deliberately absent: the slug -// (immutable by design, so it renders as muted mono text rather than an -// input) and a separate description. A definition's row `description` IS -// its display name — that is where `deriveDisplayName` reads it from — and -// the purpose blurb inside the definition's own `workflow.json` has neither -// a read nor a write route today, so this page shows no description field -// rather than a control that silently edits the display name twice. Delete -// is likewise absent: archiving is the reversible lifecycle the platform -// actually backs, and tearing down a definition's asset and history has no -// route. +// The agent's own page, addressed by its definition id — `/agents/`. +// Everything here is stock read-only observability: the definition's own +// status, its live top-level run's address, health, event log, and +// approvals. There is no editor on this page — display name, system +// prompt, model, and skill pins were all authored through +// `@corbits/agent-directory` routes that no longer exist on the hub; a +// hand-authored agent's only lifecycle is deploy (`CreateAgentPanel`) and +// observe (this page). import { Badge, - Button, - Card, - ConfirmButton, - Input, PageShell, RichEmptyState, Section, - Select, + Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, - Textarea, } from "@corbits/react-ui"; import type { BadgeTone } from "@corbits/react-ui"; -import { Copy, Robot } from "@/lib/icons"; -import { useEffect, useState, type ReactNode } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { Robot } from "@/lib/icons"; +import type { ReactNode } from "react"; -import { ApiQueryError, describeApiError, QueryView } from "@/lib/api-query"; -import { slugify } from "@/lib/slug"; +import { QueryView } from "@/lib/api-query"; -import type { - AgentDefinition, - AgentDefinitionDetail, - AgentInstance, - CatalogModel, -} from "../agents-api"; +import type { AgentDefinition, AgentInstance } from "../agents-api"; import { - clearAgentModel, - createAgentDefinition, - getAgentDefinitionBySlug, - getAgentDefinitionDetail, - setAgentDefinitionStatus, - setAgentModel, - updateAgentInstructions, - updateAgentSkills, useAgentDirectory, + useAgentRunApprovals, + useAgentRunEvents, + useAgentRunHealth, } from "../agents-api"; import type { AgentDefinitionWithDisplayName } from "../agents-directory"; import { withAgentDisplayName } from "../agents-directory"; import { useBench } from "../bench-context"; -import { runDetailPath } from "../insights-deeplinks"; -import { Link } from "../navigation"; import { AGENTS_PATH_PREFIX } from "../path-ids"; -import { tenantKeys } from "../query-client"; import { StageTopBar } from "../shell/stage-top-bar"; -import { AgentSkillsPicker } from "./agent-skills-picker"; const STATUS_TONE: Record<"deployed" | "stopped", BadgeTone> = { deployed: "success", @@ -105,406 +57,191 @@ const RUN_STATUS_COPY: Record< stopped: { label: "Stopped", tone: "neutral" }, }; -/** How many of a definition's runs the page lists. Recent history, not a - * runs browser — Insights owns that surface, and every row here links - * into it. */ -const RECENT_RUN_LIMIT = 10; +const HEALTH_TONE: Record<"ok" | "unhealthy" | "not_ready", BadgeTone> = { + ok: "success", + unhealthy: "danger", + not_ready: "neutral", +}; -function sameSkillSet(a: readonly string[], b: readonly string[]): boolean { - return a.length === b.length && a.every((name) => b.includes(name)); +/** The message half of a non-ready `APIQuery` — "unauthenticated" carries + * no message of its own, so it reads as a plain sign-in prompt instead. */ +function queryFailureMessage( + query: + | { readonly kind: "unauthenticated" } + | { readonly kind: "error"; readonly message: string }, +): string { + return query.kind === "unauthenticated" ? "You're signed out." : query.message; } -/** The runs to show for a definition: its own, newest first, capped. */ -export function recentRunsForDefinition( - runs: readonly AgentInstance[], - definitionId: string, -): readonly AgentInstance[] { - return runs - .filter((run) => run.definitionId === definitionId) - .toSorted((left, right) => right.createdAt.localeCompare(left.createdAt)) - .slice(0, RECENT_RUN_LIMIT); +function HealthRow({ tenantId, runId }: { readonly tenantId: string; readonly runId: string }) { + const health = useAgentRunHealth(tenantId, runId); + if (health.kind === "loading") return ; + if (health.kind !== "ready") { + return ( + + {queryFailureMessage(health)} + + ); + } + return ( + + + Liveness: {health.data.liveness} + + + Readiness: {health.data.readiness} + + + ); } -/** The handle a duplicate is created under: the original's slug plus a - * `-copy` suffix, kebab-safe. A second duplicate collides on the handle - * and surfaces that collision in words rather than guessing `-copy-2`. */ -export function duplicateHandle(slug: string): string { - return slugify(`${slug}-copy`); +function EventsSection({ tenantId, runId }: { readonly tenantId: string; readonly runId: string }) { + const events = useAgentRunEvents(tenantId, runId); + if (events.kind === "loading") return ; + if (events.kind !== "ready") { + return ( +

+ {queryFailureMessage(events)} +

+ ); + } + if (events.data.length === 0) { + return

No events yet.

; + } + return ( + + + + Seq + Type + + + + {events.data.map((event) => ( + + {event.seq} + {event.type} + + ))} + +
+ ); } -/** The parts a Save writes, each its own request. */ -export type SavePart = "instructions" | "model" | "skills"; - -const SAVE_PART_COPY: Record = { - instructions: "name and system prompt", - model: "default model", - skills: "skills", -}; - -/** - * What a Save actually did. `failed` names the first part that did not - * land; `saved` names the parts that already committed before it, which is - * the whole reason this is reported rather than a bare error — those writes - * are real and the person needs to know they happened. - */ -export type SaveReport = { - readonly saved: readonly SavePart[]; - readonly failed: { readonly part: SavePart; readonly message: string } | null; -}; - -/** The sentence a report reads as. */ -export function describeSaveReport(report: SaveReport): string { - const saved = report.saved.map((part) => SAVE_PART_COPY[part]).join(", "); - if (report.failed === null) { - return `Saved ${saved}.`; +function ApprovalsSection({ + tenantId, + runId, +}: { + readonly tenantId: string; + readonly runId: string; +}) { + const approvals = useAgentRunApprovals(tenantId, runId); + if (approvals.kind === "loading") return ; + if (approvals.kind !== "ready") { + return ( +

+ {queryFailureMessage(approvals)} +

+ ); + } + if (approvals.data.approvals.length === 0) { + return

No approvals yet.

; } - const failed = `Couldn't save this agent's ${SAVE_PART_COPY[report.failed.part]}: ${report.failed.message}`; - return report.saved.length === 0 - ? failed - : `Saved ${saved} — then ${failed.slice(0, 1).toLowerCase()}${failed.slice(1)}`; + return ( + + + + Tool + Scope + Status + + + + {approvals.data.approvals.map((approval) => ( + + + {typeof approval.toolDefinition.name === "string" + ? approval.toolDefinition.name + : approval.id} + + {approval.scope ?? "once"} + {approval.status} + + ))} + +
+ ); } -type WriteState = - | { readonly kind: "idle" } - | { readonly kind: "busy" } - | { readonly kind: "error"; readonly message: string }; - export function AgentDetailPage({ tenantId, definition, - detail, - models, - runs, - saveReport, - onSaved, - onDuplicated, - onStatusChanged, - skillsError, + run, }: { readonly tenantId: string; readonly definition: AgentDefinitionWithDisplayName; - readonly detail: AgentDefinitionDetail; - readonly models: readonly CatalogModel[]; - readonly runs: readonly AgentInstance[]; - /** The outcome of the Save that produced the state now on screen, held by - * the route so it survives the reload a Save triggers. */ - readonly saveReport: SaveReport | null; - readonly onSaved: (report: SaveReport) => void; - readonly onDuplicated: (slug: string) => Promise; - readonly onStatusChanged: () => void; - /** Set when the bench directory's attached-skills batch failed. Distinct - * from an agent that simply has no pins. */ - readonly skillsError?: string; + /** This definition's own live top-level run, if it has been triggered + * since deploy. `null` reads as "deployed, never yet run". */ + readonly run: AgentInstance | null; }) { - const [displayName, setDisplayName] = useState(definition.displayName); - const [systemPrompt, setSystemPrompt] = useState(detail.systemPrompt); - const [model, setModel] = useState(detail.model ?? ""); - const [skills, setSkills] = useState(detail.skills); - const [save, setSave] = useState({ kind: "idle" }); - const [lifecycle, setLifecycle] = useState({ kind: "idle" }); - const archived = definition.status === "stopped"; - const trimmedName = displayName.trim(); - const trimmedPrompt = systemPrompt.trim(); - const instructionsDirty = - trimmedName !== definition.displayName || trimmedPrompt !== detail.systemPrompt; - // Compared against the loaded value alone, so picking "Bench default" on - // an agent with a pinned model is a real edit — clearing a model is a - // change like any other, not the absence of one. - const modelDirty = model !== (detail.model ?? ""); - const skillsDirty = !sameSkillSet(skills, detail.skills); - const dirty = instructionsDirty || modelDirty || skillsDirty; - const saveable = dirty && trimmedName !== "" && trimmedPrompt !== "" && save.kind !== "busy"; - - async function onSave() { - setSave({ kind: "busy" }); - const parts: readonly { part: SavePart; write: () => Promise }[] = [ - ...(instructionsDirty - ? [ - { - part: "instructions" as const, - write: () => - updateAgentInstructions(tenantId, definition.id, { - name: trimmedName, - systemPrompt: trimmedPrompt, - }), - }, - ] - : []), - ...(modelDirty - ? [ - { - part: "model" as const, - write: () => - model === "" - ? clearAgentModel(tenantId, definition.id) - : setAgentModel(tenantId, definition.id, model), - }, - ] - : []), - ...(skillsDirty - ? [ - { - part: "skills" as const, - write: () => updateAgentSkills(tenantId, definition.id, [...skills]), - }, - ] - : []), - ]; - - const saved: SavePart[] = []; - for (const { part, write } of parts) { - try { - await write(); - saved.push(part); - } catch (cause: unknown) { - setSave({ kind: "idle" }); - // Reported, not thrown away: the parts already in `saved` are - // committed on the server, so the page reloads to show them rather - // than leaving a screen that disagrees with what was written. - onSaved({ - saved, - failed: { - part, - message: describeApiError(cause, `saving this agent's ${SAVE_PART_COPY[part]}`), - }, - }); - return; - } - } - setSave({ kind: "idle" }); - onSaved({ saved, failed: null }); - } - - async function onDuplicate() { - setLifecycle({ kind: "busy" }); - const handle = duplicateHandle(definition.name); - try { - await createAgentDefinition(tenantId, { - name: `${definition.displayName} copy`, - handle, - systemPrompt: detail.systemPrompt, - ...(detail.model !== undefined ? { model: detail.model } : {}), - skills: [...detail.skills], - }); - await onDuplicated(handle); - setLifecycle({ kind: "idle" }); - } catch (cause: unknown) { - // A handle collision is the one failure retrying can never clear, so - // it says what already exists instead of "try again". - const conflict = cause instanceof ApiQueryError && cause.status === 409; - setLifecycle({ - kind: "error", - message: conflict - ? `"${handle}" already exists — open that copy, or rename it, before duplicating this agent again.` - : describeApiError(cause, "duplicating this agent"), - }); - } - } - - async function onToggleArchived() { - setLifecycle({ kind: "busy" }); - try { - await setAgentDefinitionStatus(tenantId, definition.id, archived ? "deployed" : "stopped"); - setLifecycle({ kind: "idle" }); - onStatusChanged(); - } catch (cause: unknown) { - setLifecycle({ - kind: "error", - message: describeApiError( - cause, - archived ? "restoring this agent" : "archiving this agent", - ), - }); - } - } - - const recent = recentRunsForDefinition(runs, definition.id); return (
- - void onToggleArchived()} - aria-label={archived ? "Restore this agent" : "Archive this agent"} - > - {archived ? "Restore" : "Archive"} - - - - } />
- {saveReport !== null ? ( -

- {describeSaveReport(saveReport)} -

- ) : null} - {lifecycle.kind === "error" ? ( -

- {lifecycle.message} -

- ) : null} - {dirty ? ( -

- Unsaved edits — Duplicate copies the saved version, so it waits until you save. -

- ) : null} - - -
- - setDisplayName(event.target.value)} - /> -

{definition.name}

-

- The handle above is this agent's address and its URL. It never changes. -

-
+
+

{definition.displayName}

+

{definition.name}

{archived ? "Archived" : "Active"} - - {archived - ? "Archived — nobody can start a new conversation with it until it is restored. Conversations already running keep going." - : "Active — anyone in this workbench can talk to it."} - + {run !== null ? ( + + {RUN_STATUS_COPY[run.status].label} + + ) : null}
-
- - {models.length === 0 ? ( -

- No models in this bench's catalog yet — connect a provider in Settings and - this agent can pick one. -

- ) : ( - - )} -
- - -
-