diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index ef037ab3..c583b0d9 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -83,7 +83,7 @@ const AGENT_ROSTER_STATUS_LABEL: Record = { // the same liveness marker that vocabulary already carries); idle is the // green "healthy, nothing wrong" tone (`pill-ok` in the spec); blocked and // archived were already right. -const AGENT_ROSTER_STATUS_TONE: Record = { +export const AGENT_ROSTER_STATUS_TONE: Record = { running: "info", idle: "success", blocked: "danger", diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 8c1d2652..b04e84be 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -26,6 +26,8 @@ import { TimeSeriesChart, TokenMosaic, TraceWaterfall, + type BadgeTone, + type RunStatus, type TraceSpan, } from "@corbits/react-ui"; import { ChartBar } from "@corbits/icons"; @@ -50,6 +52,7 @@ import { type OverallUsage, } from "@corbits/insights/client"; +import type { WorkflowRunStatus } from "@intx/types"; import { SignedOutNotice, type APIQuery } from "@corbits/api-query"; import { workbenchesQueryKey, @@ -128,30 +131,23 @@ export function formatWhen(iso: string): string { }); } -export function statusTone( - status: string, -): "success" | "warning" | "danger" | "neutral" | "info" { - switch (status) { - case "completed": - case "succeeded": - case "ok": - case "deployed": - return "success"; - case "running": - case "pending": - case "awaiting": - case "updating": - return "info"; - case "failed": - case "errored": - case "error": - return "danger"; - case "cancelled": - case "stopped": - return "warning"; - default: - return "neutral"; - } +/** A platform workflow run's status (`WorkflowRunStatus`) doesn't spell + * react-ui's `RunStatus` vocabulary the same way — normalize onto it here + * so the badge tone always comes from `RUN_STATUS_TONE`, the one source + * every run-status tone reads from, rather than a second opinion invented + * on this page. */ +const WORKFLOW_RUN_STATUS_ALIAS: Readonly< + Record +> = { + deployed: "completed", + running: "running", + updating: "running", + error: "failed", + stopped: "stopped", +}; + +export function statusTone(status: WorkflowRunStatus): BadgeTone { + return RUN_STATUS_TONE[WORKFLOW_RUN_STATUS_ALIAS[status]]; } function tileValue(value: string | number | null, loading: boolean): string { diff --git a/apps/web/src/pages/mission-control-page.tsx b/apps/web/src/pages/mission-control-page.tsx index 8f0aafa0..49e4fa90 100644 --- a/apps/web/src/pages/mission-control-page.tsx +++ b/apps/web/src/pages/mission-control-page.tsx @@ -12,6 +12,7 @@ import { Button, PageShell, RichEmptyState, + RUN_STATUS_TONE, Skeleton, StatGrid, StatGridItem, @@ -73,7 +74,14 @@ function taskInFlightRow(task: WorkingTask): InFlightRow { context: `${task.agentName} · task`, createdAt: task.createdAt, statusLabel, - statusTone: task.status === "needs-you" ? "accent" : "info", + // needs-you is react-ui's `awaiting` (the one status a person can act + // on); every other in-flight task reads `running` — both read their + // tone from react-ui's own `RUN_STATUS_TONE` rather than a hand-picked + // one. + statusTone: + task.status === "needs-you" + ? RUN_STATUS_TONE.awaiting + : RUN_STATUS_TONE.running, // stepCount is the task's planned total; runIds is how many legs have // actually dispatched so far — a real ratio, not an invented total. steps: `${task.runIds.length}/${task.stepCount}`, @@ -87,7 +95,7 @@ function routineInFlightRow(routine: RoutineActivityItem): InFlightRow { context: "routine", createdAt: routine.startedAt, statusLabel: "Running", - statusTone: "info", + statusTone: RUN_STATUS_TONE.running, // The routine feed carries no step count — an honest dash, not a guess. steps: "—", }; diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index 5bc09002..e6961e2d 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -22,6 +22,7 @@ import { EmptyState, formatRelativeTime, RichEmptyState, + RUN_STATUS_TONE, RunNowButton, Switch, Table, @@ -31,7 +32,7 @@ import { TableHeader, TableRow, } from "@corbits/react-ui"; -import type { BadgeTone } from "@corbits/react-ui"; +import type { BadgeTone, RunStatus } from "@corbits/react-ui"; import { Clock, PlayCircle, Plus } from "@corbits/icons"; import type { KeyboardEvent } from "react"; import { @@ -58,13 +59,24 @@ import type { RoutineRun } from "../routines-api"; export type { GlobalRoutineRow } from "../global-routines"; -const RUN_STATUS_TONE: Record = { - running: "info", - completed: "success", - failed: "danger", - cancelled: "neutral", +/** Platform run status strings that don't spell their canonical + * `RunStatus` name the same way — everything else already matches + * react-ui's `RunStatus` vocabulary and passes through unchanged. */ +const RUN_STATUS_ALIAS: Readonly> = { + cancelled: "stopped", + queued: "provisioning", + pending: "provisioning", }; +/** Tone for a platform run status, normalized onto `RunStatus` first so + * this reads the same map every other surface does + * (`RUN_STATUS_TONE` in react-ui's `workflow-run.ts`) instead of a second, + * drifting opinion. */ +export function runStatusTone(status: string): BadgeTone { + const canonicalStatus = RUN_STATUS_ALIAS[status] ?? (status as RunStatus); + return RUN_STATUS_TONE[canonicalStatus] ?? "neutral"; +} + /** * Recent-run rows deep-link to the workbench the routine delivers to — a * routine has one `deliveryWorkbenchId`, not a per-run one, so every row @@ -171,11 +183,7 @@ export function RunStatusCell({ run }: { readonly run: RoutineRun }) { if (typeof status !== "string") { return ; } - return ( - - {runStatusLabel(status)} - - ); + return {runStatusLabel(status)}; } /** A routine's health, from the telemetry the scheduler already records — diff --git a/apps/web/src/pages/run-status-tone-parity.test.ts b/apps/web/src/pages/run-status-tone-parity.test.ts new file mode 100644 index 00000000..b5102c1e --- /dev/null +++ b/apps/web/src/pages/run-status-tone-parity.test.ts @@ -0,0 +1,90 @@ +// Guards against the exact bug this file was added for (CL-6499 design +// review): a page grows its own run-status → tone map/function instead of +// reading react-ui's `RUN_STATUS_TONE` (`workflow-run.ts`), and it quietly +// disagrees — a cancelled run reading neutral grey on Routines and amber +// warning on Insights, the same status meaning two different things in one +// product. `RUN_STATUS_TONE` is the one place a run-status tone is allowed +// to be decided; every surface below must normalize its own status +// vocabulary onto react-ui's `RunStatus` and read the tone from there. +// +// A static scan can't verify this: the divergence this catches was a +// `case "stopped": return "warning"` inside a switch, not an object +// literal, and re-deriving "is this tone value equal to canonical" from +// source text would mean parsing arbitrary JS — the general-purpose lint +// framework this ticket explicitly says not to build. Calling the real +// exported code and comparing its output to `RUN_STATUS_TONE` catches both +// shapes (switch or map) with no parser. +import { describe, expect, test } from "bun:test"; + +import { RUN_STATUS_TONE } from "@corbits/react-ui"; + +import { statusTone } from "./insights-page"; +import { runStatusTone } from "./routines-page"; +import { computeInFlightRows } from "./mission-control-page"; +import { AGENT_ROSTER_STATUS_TONE } from "./agents-page"; +import type { WorkingTask } from "@corbits/tasks-ui"; +import type { RoutineActivityItem } from "../shell/routine-activity"; + +function workingTask(overrides: Partial): WorkingTask { + return { + id: "tsk_1", + definitionId: "def_1", + workbenchId: null, + agentName: "Myra", + prompt: "Do the thing", + modelPreference: null, + status: "running", + runId: "run_1", + runIds: ["run_1"], + stepCount: 1, + resultMailId: null, + createdAt: new Date().toISOString(), + completedAt: null, + ...overrides, + }; +} + +describe("run-status tone parity with react-ui's RUN_STATUS_TONE", () => { + test("Insights' statusTone agrees with canonical for every shared status", () => { + // WorkflowRunStatus ("running"/"stopped") spells these two the same way + // RunStatus does — the exact pair the reviewer caught disagreeing. + expect(statusTone("running")).toBe(RUN_STATUS_TONE.running); + expect(statusTone("stopped")).toBe(RUN_STATUS_TONE.stopped); + }); + + test("Routines' runStatusTone agrees with canonical for every shared status", () => { + expect(runStatusTone("running")).toBe(RUN_STATUS_TONE.running); + expect(runStatusTone("completed")).toBe(RUN_STATUS_TONE.completed); + expect(runStatusTone("failed")).toBe(RUN_STATUS_TONE.failed); + }); + + test("Mission Control's in-flight rows agree with canonical for every shared status", () => { + const [needsYouRow] = computeInFlightRows( + [workingTask({ id: "tsk_needs_you", status: "needs-you" })], + [], + ); + expect(needsYouRow?.statusTone).toBe(RUN_STATUS_TONE.awaiting); + + const [runningRow] = computeInFlightRows( + [workingTask({ id: "tsk_running", status: "running" })], + [], + ); + expect(runningRow?.statusTone).toBe(RUN_STATUS_TONE.running); + + const routine: RoutineActivityItem = { + id: "rtn_1", + name: "Daily brief", + status: "running", + startedAt: new Date().toISOString(), + }; + const [routineRow] = computeInFlightRows([], [routine]); + expect(routineRow?.statusTone).toBe(RUN_STATUS_TONE.running); + }); + + test("Agents' roster status tone (a genuinely different vocabulary) still agrees where it overlaps", () => { + // AgentRosterStatus is its own enum, not RunStatus — this is the one + // local map the ticket calls defensible. It only shares one name + // ("running") with RunStatus, and it must keep agreeing on that one. + expect(AGENT_ROSTER_STATUS_TONE.running).toBe(RUN_STATUS_TONE.running); + }); +});