diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index 59dd57a494..f5575c52ae 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -14,7 +14,7 @@ import { type PaletteSource, type RecentEntry, } from "@/command-palette"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { listAgentDefinitions } from "./agents-api"; @@ -183,49 +183,25 @@ export function CommandPaletteProvider({ // fetches for (by design — the unscoped default view should not dump // every entity on open). The mock shows every item in an active scope for // this input, so fetch that scope's raw list directly instead. - const [bareWorkbenches, setBareWorkbenches] = useState([]); - const [bareAgents, setBareAgents] = useState([]); - - useEffect(() => { - if (bareScopeKind !== "workbenches" || !open) { - setBareWorkbenches([]); - return; - } - let cancelled = false; - void listWorkbenchesForSearch().then((rows) => { - if (cancelled) return; - setBareWorkbenches( - rows.map((row) => ({ - id: `entity:workbenches:${row.id}`, - title: row.name, - })), - ); - }); - return () => { - cancelled = true; - }; - }, [bareScopeKind, open, listWorkbenchesForSearch]); + const bareWorkbenchesQuery = useQuery({ + queryKey: [...tenantKeys.workbenches(selectedTenantId ?? "", "workbench"), "bare-scope"], + enabled: bareScopeKind === "workbenches" && open && selectedTenantId !== null, + queryFn: listWorkbenchesForSearch, + }); + const bareWorkbenches: readonly PaletteResultItem[] = (bareWorkbenchesQuery.data ?? []).map( + (row) => ({ id: `entity:workbenches:${row.id}`, title: row.name }), + ); - useEffect(() => { - if (bareScopeKind !== "people" || !open) { - setBareAgents([]); - return; - } - let cancelled = false; - void listAgentsForSearch().then((rows) => { - if (cancelled) return; - setBareAgents( - rows.map((row) => ({ - id: `entity:agents:${row.id}`, - title: row.name, - subtitle: "Agent", - })), - ); - }); - return () => { - cancelled = true; - }; - }, [bareScopeKind, open, listAgentsForSearch]); + const bareAgentsQuery = useQuery({ + queryKey: ["tenant", selectedTenantId ?? "", "agents", "bare-scope"], + enabled: bareScopeKind === "people" && open && selectedTenantId !== null, + queryFn: listAgentsForSearch, + }); + const bareAgents: readonly PaletteResultItem[] = (bareAgentsQuery.data ?? []).map((row) => ({ + id: `entity:agents:${row.id}`, + title: row.name, + subtitle: "Agent", + })); const routinesQuery = useTenantQuery( tenantKeys.routines(selectedTenantId ?? ""), diff --git a/apps/web/src/pages/mission-control-page.tsx b/apps/web/src/pages/mission-control-page.tsx index 805608d388..ce23566d5c 100644 --- a/apps/web/src/pages/mission-control-page.tsx +++ b/apps/web/src/pages/mission-control-page.tsx @@ -29,8 +29,7 @@ import { } from "@corbits/react-ui"; import type { BadgeTone } from "@corbits/react-ui"; import { ChatCircleDots, Plus, Robot } from "@/lib/icons"; -import { useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CHAT_STRINGS, type Workbench } from "@/chat"; import { runOutcomeStatus, runStatusLabel, withListingAbandoned } from "@corbits/workflows/client"; @@ -153,26 +152,23 @@ function ApprovalRow({ readonly tenantId: string; }) { const queryClient = useQueryClient(); - const [pending, setPending] = useState<"approve" | "deny" | null>(null); - - async function resolve(action: "approve" | "deny") { - setPending(action); - try { - if (action === "approve") await approveApproval(tenantId, item.id); - else await rejectApproval(tenantId, item.id); - await queryClient.invalidateQueries({ + const resolveMutation = useMutation({ + mutationFn: (action: "approve" | "deny") => + action === "approve" ? approveApproval(tenantId, item.id) : rejectApproval(tenantId, item.id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: tenantKeys.pendingApprovals(tenantId), }); - } catch (cause) { + }, + onError: (cause, action) => { toast( cause instanceof Error ? cause.message : `Couldn't ${action === "approve" ? "approve" : "deny"} that request.`, ); - } finally { - setPending(null); - } - } + }, + }); + const pending = resolveMutation.isPending ? resolveMutation.variables : null; return ( @@ -187,7 +183,7 @@ function ApprovalRow({ variant="ghost" size="sm" disabled={pending !== null} - onClick={() => void resolve("deny")} + onClick={() => resolveMutation.mutate("deny")} > {pending === "deny" ? "Denying…" : "Deny"} @@ -195,7 +191,7 @@ function ApprovalRow({ variant="primary" size="sm" disabled={pending !== null} - onClick={() => void resolve("approve")} + onClick={() => resolveMutation.mutate("approve")} > {pending === "approve" ? "Approving…" : "Approve"} diff --git a/apps/web/src/pages/workflow-detail-page.tsx b/apps/web/src/pages/workflow-detail-page.tsx index 202741346c..b674bb1ddd 100644 --- a/apps/web/src/pages/workflow-detail-page.tsx +++ b/apps/web/src/pages/workflow-detail-page.tsx @@ -1,10 +1,28 @@ // `/workflows/` — a workflow definition's own page. Stock's // `GET /workflows/definitions` (`workflow-detail-api.ts`) exposes only // name, description, status (`deployed` | `stopped`), current version, -// and timestamps, so this renders exactly that. -import { Badge, EmptyState, PageShell } from "@corbits/react-ui"; +// and timestamps; runs and their event logs come from the stock +// `GET /workflows/runs?definitionId=` listing and `GET +// /workflows/runs/:runId/events` (`vendor/intx/hub-api/src/routes/runs.ts`). +import { + Badge, + EmptyState, + PageShell, + RichEmptyState, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@corbits/react-ui"; import type { BadgeTone } from "@corbits/react-ui"; +import { WorkflowRunResponse, paginatedSchema } from "@intx/types"; +import { type } from "arktype"; +import { useState } from "react"; import { Clock, FlowArrow } from "@/lib/icons"; +import { useAPIQuery } from "../api"; import { useBench } from "../bench-context"; import { WORKFLOWS_PATH_PREFIX, workflowDefinitionAssetIdFromPath } from "../path-ids"; @@ -17,6 +35,137 @@ import { type WorkflowDefinitionDetailT, } from "../workflow-detail-api"; +const RunsPageSchema = paginatedSchema(WorkflowRunResponse); +type RunRow = typeof WorkflowRunResponse.infer; + +const RunEventSchema = type({ seq: "number", type: "string", body: "Record" }); +const RunEventsSchema = type({ runId: "string", events: RunEventSchema.array() }); +type RunEvent = typeof RunEventSchema.infer; + +function runsPath(tenantId: string, definitionId: string): string { + return `/api/tenants/${tenantId}/workflows/runs?definitionId=${encodeURIComponent(definitionId)}&limit=100`; +} + +function runEventsPath(tenantId: string, runId: string): string { + return `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/events`; +} + +const RUN_STATUS_TONE_BY_STATUS: Readonly> = { + deployed: "success", + running: "info", + updating: "info", + error: "danger", + stopped: "neutral", +}; + +/** A run's event log, fetched on selection. Rendered inline under the runs + * table rather than a separate route — a definition rarely has enough runs + * to need one. */ +function RunEventsPanel({ + tenantId, + runId, +}: { + readonly tenantId: string; + readonly runId: string; +}) { + const eventsQuery = useAPIQuery(runEventsPath(tenantId, runId), RunEventsSchema); + if (eventsQuery.kind === "loading") return ; + if (eventsQuery.kind === "error") { + return ; + } + if (eventsQuery.kind === "unauthenticated") return null; + const events: readonly RunEvent[] = eventsQuery.data.events; + if (events.length === 0) { + return ( + + ); + } + return ( + + + + Seq + Type + + + + {events.map((event) => ( + + {event.seq} + {event.type} + + ))} + +
+ ); +} + +/** Runs for this definition, newest first (the listing's own order), with a + * click-to-expand event log per run. */ +function WorkflowRunsSection({ + tenantId, + definitionId, +}: { + readonly tenantId: string; + readonly definitionId: string; +}) { + const [selectedRunId, setSelectedRunId] = useState(null); + const runsQuery = useAPIQuery(runsPath(tenantId, definitionId), RunsPageSchema); + + return ( +
+

Runs

+ {runsQuery.kind === "loading" ? : null} + {runsQuery.kind === "error" ? ( + + ) : null} + {runsQuery.kind === "ready" && runsQuery.data.data.length === 0 ? ( + + ) : null} + {runsQuery.kind === "ready" && runsQuery.data.data.length > 0 ? ( + + + + Status + Started + Ended + + + + {runsQuery.data.data.map((run) => ( + <> + setSelectedRunId(selectedRunId === run.id ? null : run.id)} + > + + {run.status} + + {run.createdAt} + {run.endedAt ?? "—"} + + {selectedRunId === run.id ? ( + + + + + + ) : null} + + ))} + +
+ ) : null} +
+ ); +} + const STATUS_LABEL: Readonly> = { deployed: "Deployed", stopped: "Stopped", @@ -60,9 +209,16 @@ export function NotLaunchableStrip({ ); } -/** The whole page body, given a resolved detail — pure, so the layout is - * testable without a fetch or a router. */ -export function WorkflowDetailPage({ detail }: { readonly detail: WorkflowDefinitionDetailT }) { +/** The whole page body, given a resolved detail and the tenant it lives on. + * The header/status strip render straight off `detail`; runs and their + * event logs are their own live reads (`WorkflowRunsSection`). */ +export function WorkflowDetailPage({ + detail, + tenantId, +}: { + readonly detail: WorkflowDefinitionDetailT; + readonly tenantId: string; +}) { return (
+
@@ -132,5 +289,5 @@ export function WorkflowDetailRoute({ path }: { readonly path: string }) { return ; } - return ; + return ; } diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index 9ec338be15..f2eeefbfaa 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -1,26 +1,24 @@ -// The Routines page's seam to authored workflow definitions that carry a -// ScheduleTrigger: list, run-now, and pause/resume. Pause/resume is the -// same agent-directory status PUT seed uses (`stopped` / `deployed`). +// The Routines page's seam to stock workflow deployments: list, run-now, +// pause/resume. `@corbits/workflows`'s deleted `./schedule/scheduled-route.ts` +// (and the hub route it backed) is gone; this reads the stock deployments +// listing (`GET /workflows/deployments`) joined against the tenant's workflow +// assets (`GET /assets?kind=workflow`) for a display name, exactly the two +// stock reads `vendor/intx/hub-api/src/routes/workflows.ts` exposes. // -// the hub route `listScheduledWorkflows`/`runScheduledWorkflowNow` used -// to call (`@corbits/workflows`'s deleted `./schedule/scheduled-route.ts`) -// is gone. Owner ruling: schedule state derives from stock reads -// client-side or stays a package-internal pure function — no hub route. -// Stock's `GET /workflows/definitions` (`vendor/intx/hub-api`) exposes no -// wire projection, so there is no stock-derivable way to know which -// definitions carry a `ScheduleTrigger` or what its cron is. It resolves -// to an empty list rather than fetch a route that no longer exists (same -// pattern as `tenantKeys`' `routineActivity` comment in `query-client.ts` -// for its deleted `feed=fires` route): the Routines roster renders its -// existing empty state until an upstream ask lands. +// Stock's deployment row carries no schedule: a `ScheduleTrigger`'s cron +// lives in the deployed source's `workflow.json`, unreachable from either +// listing read, so `cron` always reports "manual" here — honest rather than +// guessed — until an upstream read exposes it. Run-now and pause/resume have +// no backing stock route either (`/deployments` is list/create only; no +// per-deployment PATCH or trigger route exists), so both stay rejected +// promises with a message naming the missing route, same pattern as before. import { type } from "arktype"; import { useQuery } from "@tanstack/react-query"; +import { WorkflowDeploymentResponse } from "@intx/types"; import type { APIQuery } from "@/lib/api-query"; import { ApiQueryError, UnauthenticatedError, toAPIQuery } from "@/lib/api-query"; -import { setAgentDefinitionStatus } from "./agents-api"; - export const ScheduledWorkflowDefinition = type({ definitionId: "string", assetId: "string", @@ -34,48 +32,83 @@ export const ScheduledWorkflowDefinition = type({ export type ScheduledWorkflowDefinition = typeof ScheduledWorkflowDefinition.infer; -/** no route exists at this path any more — kept as a documented - * dead address, not a live fetch target, for any caller that still reads - * it for logging/keys. */ -export function scheduledWorkflowsPath(tenantId: string): string { - return `/api/tenants/${tenantId}/workflows/scheduled`; +const DeploymentsSchema = WorkflowDeploymentResponse.array(); +const WorkflowAssetSchema = type({ id: "string", name: "string" }); +const WorkflowAssetsSchema = WorkflowAssetSchema.array(); + +function deploymentsPath(tenantId: string): string { + return `/api/tenants/${tenantId}/workflows/deployments`; +} + +function workflowAssetsPath(tenantId: string): string { + return `/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`; } -export function scheduledWorkflowRunPath(tenantId: string, definitionId: string): string { - return `/api/tenants/${tenantId}/workflows/scheduled/${encodeURIComponent(definitionId)}/run`; +async function fetchJSON(path: string, schema: (data: unknown) => T | type.errors): Promise { + const response = await fetch(path, { headers: { accept: "application/json" } }); + if (response.status === 401) throw new UnauthenticatedError(); + if (!response.ok) { + throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); + } + const parsed = schema(await response.json()); + if (parsed instanceof type.errors) { + throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path); + } + return parsed; } -/** always empty — see the file header for why there is no - * stock-derivable replacement yet. */ -export function listScheduledWorkflows( - _tenantId: string, +/** Every workflow deployment on this tenant, named from the backing + * workflow asset. A deployment whose asset can't be resolved still lists, + * under a placeholder name, rather than disappearing. */ +export async function listScheduledWorkflows( + tenantId: string, ): Promise { - return Promise.resolve([]); + const [deployments, assets] = await Promise.all([ + fetchJSON(deploymentsPath(tenantId), DeploymentsSchema), + fetchJSON(workflowAssetsPath(tenantId), WorkflowAssetsSchema), + ]); + const nameByAssetId = new Map(assets.map((asset) => [asset.id, asset.name])); + return deployments.map((deployment) => ({ + definitionId: deployment.id, + assetId: deployment.definitionAssetId, + name: nameByAssetId.get(deployment.definitionAssetId) ?? "Untitled workflow", + tenantId: deployment.tenantId, + status: deployment.status === "deployed" ? "deployed" : "stopped", + cron: "manual", + createdAt: deployment.createdAt, + updatedAt: deployment.createdAt, + })); } -/** unreachable in practice — `listScheduledWorkflows` never - * returns a row for `onRunNow` to be called with — kept only so - * `useRoutineActions`' shape does not need to change too. Throws rather - * than fetching a route that no longer exists. */ +/** No stock route reruns a deployment on demand yet. */ export function runScheduledWorkflowNow( - _tenantId: string, - _definitionId: string, + tenantId: string, + definitionId: string, ): Promise<{ runId: string }> { return Promise.reject( new ApiQueryError( - "Scheduled run-now has no stock route yet.", + "Running a workflow now has no stock route yet.", undefined, - scheduledWorkflowRunPath(_tenantId, _definitionId), + `/api/tenants/${tenantId}/workflows/deployments/${encodeURIComponent(definitionId)}/run`, ), ); } +/** No stock route pauses or resumes a deployment yet. */ export function setScheduledWorkflowStatus( tenantId: string, definitionId: string, status: "deployed" | "stopped", ): Promise<{ readonly status: string }> { - return setAgentDefinitionStatus(tenantId, definitionId, status); + return Promise.reject( + new ApiQueryError( + status === "deployed" + ? "Resuming a deployment has no stock route yet." + : "Pausing a deployment has no stock route yet.", + undefined, + `/api/tenants/${tenantId}/workflows/deployments/${encodeURIComponent(definitionId)}`, + ), + ); } /**