feat(ui): fold the Workflows page into Dashboard → Analytics → Workflow

The workflow analytics now render under the Analytics tab's Workflow
sub-tab; ?tab= and ?sub= deep-link the Dashboard, /workflows redirects there,
and the sidebar drops its Workflows item.
This commit is contained in:
2026-10-01 22:05:55 +07:00
parent b71024f244
commit 744e9c6cda
14 changed files with 121 additions and 98 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+9 -9
View File
@@ -75,11 +75,10 @@ graph TB
Layout[Layout Component] Layout[Layout Component]
subgraph "Pages" subgraph "Pages"
Home[Dashboard<br/>Monitor / Analytics / Health tabs] Home[Dashboard<br/>Monitor / Analytics (incl. Workflow) / Health tabs]
Sessions[Sessions] Sessions[Sessions]
Detail[SessionDetail] Detail[SessionDetail]
Feed[ActivityFeed] Feed[ActivityFeed]
Workflows[Workflows]
Settings[Settings] Settings[Settings]
end end
@@ -106,7 +105,7 @@ graph TB
end end
Router --> Layout Router --> Layout
Layout --> Home & Sessions & Detail & Feed & Workflows & Settings Layout --> Home & Sessions & Detail & Feed & Settings
Home & Detail --> AgentCard & StatCard & StatusBadge Home & Detail --> AgentCard & StatCard & StatusBadge
Feed --> EventDetail Feed --> EventDetail
API --> REST API --> REST
@@ -198,14 +197,14 @@ client/
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning) │ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card │ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card │ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files) │ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
│ │ │ │
│ ├── pages/ # Route pages │ ├── pages/ # Route pages
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs │ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID │ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) │ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers)
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates │ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
│ │ ├── Workflows.tsx │ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
│ │ ├── Settings.tsx │ │ ├── Settings.tsx
│ │ ├── AiToolConfig.tsx # Settings → <tool>: the generic tab of every installed, enabled catalog tool (path, Update link, sessions/cost/lanes, default model, vendor pricing, catalog-declared config files; Antigravity adds its hooks card) │ │ ├── AiToolConfig.tsx # Settings → <tool>: the generic tab of every installed, enabled catalog tool (path, Update link, sessions/cost/lanes, default model, vendor pricing, catalog-declared config files; Antigravity adds its hooks card)
│ │ └── NotFound.tsx │ │ └── NotFound.tsx
@@ -252,11 +251,12 @@ graph TB
Sessions[Sessions<br/>filterable table] Sessions[Sessions<br/>filterable table]
Detail[SessionDetail<br/>agent hierarchy + timeline] Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log] Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Lane pipelines + D3 analytics] Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktrees<br/>Projects: one page per repo] Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktrees<br/>Projects: one page per repo]
App --> Layout App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings Layout --> Dashboard & Sessions & Detail & Feed & Settings
Dashboard --> Workflows
Dashboard --> StatCard[StatCard × 6] Dashboard --> StatCard[StatCard × 6]
Dashboard --> AgentCard[AgentCard × N] Dashboard --> AgentCard[AgentCard × N]
@@ -469,7 +469,7 @@ graph TB
Sessions["/sessions (tabs: sessions | events)"] Sessions["/sessions (tabs: sessions | events)"]
Detail["/sessions/:id"] Detail["/sessions/:id"]
Feed["/activity (redirect)"] Feed["/activity (redirect)"]
Workflows["/workflows"] Workflows["/workflows (redirect → /?tab=analytics&sub=workflow)"]
CcConfig["/cc-config (redirect)"] CcConfig["/cc-config (redirect)"]
Lanes["/lanes (redirect)"] Lanes["/lanes (redirect)"]
Run["/run (redirect)"] Run["/run (redirect)"]
@@ -500,7 +500,7 @@ function App() {
<Route path="sessions/:id" element={<SessionDetail />} /> <Route path="sessions/:id" element={<SessionDetail />} />
{/* legacy path - the activity feed is now a tab on /sessions */} {/* legacy path - the activity feed is now a tab on /sessions */}
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} /> <Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Workflows />} /> <Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
{/* legacy path - Claude config now lives in a Settings tab */} {/* legacy path - Claude config now lives in a Settings tab */}
<Route path="cc-config" element={<Navigate to="/settings/agents/claude-code" replace />} /> <Route path="cc-config" element={<Navigate to="/settings/agents/claude-code" replace />} />
{/* legacy paths - lanes and runs are one page now */} {/* legacy paths - lanes and runs are one page now */}
+4 -3
View File
@@ -41,7 +41,6 @@
* - `./pages/Dashboard` * - `./pages/Dashboard`
* - `./pages/History` * - `./pages/History`
* - `./pages/SessionDetail` * - `./pages/SessionDetail`
* - `./pages/Workflows`
* - `./pages/SettingsHub` * - `./pages/SettingsHub`
* - `./pages/Workspace` * - `./pages/Workspace`
* *
@@ -64,7 +63,6 @@ import { GlobalTooltip } from "./components/Tooltip";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { History } from "./pages/History"; import { History } from "./pages/History";
import { SessionDetail } from "./pages/SessionDetail"; import { SessionDetail } from "./pages/SessionDetail";
import { Workflows } from "./pages/Workflows";
import { SettingsHub } from "./pages/SettingsHub"; import { SettingsHub } from "./pages/SettingsHub";
import { Workspace } from "./pages/Workspace"; import { Workspace } from "./pages/Workspace";
import { NotFound } from "./pages/NotFound"; import { NotFound } from "./pages/NotFound";
@@ -99,7 +97,10 @@ export default function App() {
<Route path="sessions" element={<History />} /> <Route path="sessions" element={<History />} />
<Route path="sessions/:id" element={<SessionDetail />} /> <Route path="sessions/:id" element={<SessionDetail />} />
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} /> <Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Workflows />} /> <Route
path="workflows"
element={<Navigate to="/?tab=analytics&sub=workflow" replace />}
/>
<Route path="lanes" element={<Navigate to="/workspace" replace />} /> <Route path="lanes" element={<Navigate to="/workspace" replace />} />
<Route <Route
path="cc-config" path="cc-config"
-2
View File
@@ -24,8 +24,6 @@ export function DocumentTitle() {
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`; title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
} else if (path.startsWith("/sessions")) { } else if (path.startsWith("/sessions")) {
title = t("sessions"); title = t("sessions");
} else if (path.startsWith("/workflows")) {
title = t("workflows");
} else if (path.startsWith("/workspace")) { } else if (path.startsWith("/workspace")) {
title = t("workspace"); title = t("workspace");
} else if (path.startsWith("/settings")) { } else if (path.startsWith("/settings")) {
-2
View File
@@ -55,7 +55,6 @@ import {
LayoutDashboard, LayoutDashboard,
FolderOpen, FolderOpen,
BarChart3, BarChart3,
Workflow,
Play, Play,
Settings, Settings,
Wifi, Wifi,
@@ -89,7 +88,6 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [ const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" }, { to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" }, { to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
{ to: "/workflows", icon: Workflow, key: "nav:workflows" },
{ to: "/workspace", icon: Play, key: "nav:workspace" }, { to: "/workspace", icon: Play, key: "nav:workspace" },
{ to: "/settings", icon: Settings, key: "nav:settings" }, { to: "/settings", icon: Settings, key: "nav:settings" },
] as const; ] as const;
@@ -33,7 +33,6 @@ describe("DocumentTitle", () => {
it("titles the other top-level routes", () => { it("titles the other top-level routes", () => {
expect(titleFor("/")).toMatch(/^Dashboard · /); expect(titleFor("/")).toMatch(/^Dashboard · /);
expect(titleFor("/sessions")).toMatch(/^Sessions · /); expect(titleFor("/sessions")).toMatch(/^Sessions · /);
expect(titleFor("/workflows")).toMatch(/^Workflows · /);
expect(titleFor("/settings")).toMatch(/^Settings · /); expect(titleFor("/settings")).toMatch(/^Settings · /);
}); });
}); });
-1
View File
@@ -64,7 +64,6 @@
"upToDate": "Up to date", "upToDate": "Up to date",
"updateAvailable": "Update available", "updateAvailable": "Update available",
"website": "Website", "website": "Website",
"workflows": "Workflows",
"workspace": "Workspace", "workspace": "Workspace",
"wsEndpoint": "WebSocket", "wsEndpoint": "WebSocket",
"releaseAvailable": "{{version}} available", "releaseAvailable": "{{version}} available",
-1
View File
@@ -64,7 +64,6 @@
"upToDate": "Đã là mới nhất", "upToDate": "Đã là mới nhất",
"updateAvailable": "Có bản cập nhật", "updateAvailable": "Có bản cập nhật",
"website": "Website", "website": "Website",
"workflows": "Quy trình",
"workspace": "Không gian làm việc", "workspace": "Không gian làm việc",
"wsEndpoint": "WebSocket", "wsEndpoint": "WebSocket",
"releaseAvailable": "Có {{version}}", "releaseAvailable": "Có {{version}}",
+21 -4
View File
@@ -63,7 +63,8 @@ import {
useRef, useRef,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { Workflows } from "./Workflows";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
LayoutDashboard, LayoutDashboard,
@@ -732,9 +733,14 @@ function AnalyticsTab() {
const [costData, setCostData] = useState<CostResult | null>(null); const [costData, setCostData] = useState<CostResult | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date()); const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`).
const [params] = useSearchParams();
const [analyticsSubTab, setAnalyticsSubTab] = useState< const [analyticsSubTab, setAnalyticsSubTab] = useState<
"tokens" | "cost" | "workflow" | "productivity" "tokens" | "cost" | "workflow" | "productivity"
>("cost"); >(() => {
const sub = params.get("sub");
return sub === "tokens" || sub === "workflow" || sub === "productivity" ? sub : "cost";
});
// Global data scope; a change re-runs `load` (api injects the `sources` param). // Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope(); const [scope] = useDataScope();
@@ -1340,6 +1346,7 @@ function AnalyticsTab() {
)} )}
{analyticsSubTab === "workflow" && ( {analyticsSubTab === "workflow" && (
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Agent type distribution */} {/* Agent type distribution */}
<div className="card p-5"> <div className="card p-5">
@@ -1365,7 +1372,9 @@ function AnalyticsTab() {
{/* Agent status donut */} {/* Agent status donut */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("agentStatus")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("agentStatus")}
</h3>
<DonutChart segments={agentStatusSegments} /> <DonutChart segments={agentStatusSegments} />
<div className="mt-4 pt-4 border-t border-border space-y-1.5"> <div className="mt-4 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted"> <div className="flex justify-between text-xs text-fg-muted">
@@ -1398,7 +1407,9 @@ function AnalyticsTab() {
{/* Event type breakdown */} {/* Event type breakdown */}
<div className="card p-5"> <div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("eventTypes")}</h3> <h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("eventTypes")}
</h3>
{(data?.event_types ?? []).length === 0 ? ( {(data?.event_types ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noEventData")}</p> <p className="text-sm text-fg-muted">{t("noEventData")}</p>
) : ( ) : (
@@ -1416,6 +1427,9 @@ function AnalyticsTab() {
)} )}
</div> </div>
</div> </div>
{/* The former Workflows page, folded into this sub-tab. */}
<Workflows />
</div>
)} )}
{analyticsSubTab === "productivity" && ( {analyticsSubTab === "productivity" && (
@@ -2346,7 +2360,10 @@ export function Dashboard() {
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
// Persistent Tab State // Persistent Tab State
const [tabParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<"monitor" | "analytics" | "health">(() => { const [activeTab, setActiveTab] = useState<"monitor" | "analytics" | "health">(() => {
const tab = tabParams.get("tab");
if (tab === "monitor" || tab === "analytics" || tab === "health") return tab;
return ( return (
(localStorage.getItem("dashboard_tab") as "monitor" | "analytics" | "health") || "analytics" (localStorage.getItem("dashboard_tab") as "monitor" | "analytics" | "health") || "analytics"
); );
+3 -2
View File
@@ -1,6 +1,7 @@
/** /**
* @file Workflows.tsx * @file Workflows.tsx
* @description Workflows page. Opens on this dashboard's OWN pipelines — per-node * @description The workflow analytics, shown inside Dashboard → Analytics → Workflow (the
* old `/workflows` route redirects there). Opens on this dashboard's OWN pipelines — per-node
* pass rates across every lane, and which skills/plugins actually get used — then * pass rates across every lane, and which skills/plugins actually get used — then
* the Claude Code orchestration analytics: headline stats, Workflow-tool runs, the * the Claude Code orchestration analytics: headline stats, Workflow-tool runs, the
* spawning DAG, error propagation, session complexity, compaction impact and the * spawning DAG, error propagation, session complexity, compaction impact and the
@@ -494,7 +495,7 @@ function PageHeader({
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1> <h2 className="text-lg font-semibold text-fg-primary">{t("title")}</h2>
{wsConnected ? ( {wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"> <span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
@@ -9390,11 +9390,11 @@ exports[`screen snapshots > Workflows 1`] = `
<div <div
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<h1 <h2
class="text-lg font-semibold text-fg-primary" class="text-lg font-semibold text-fg-primary"
> >
Workflows Workflows
</h1> </h2>
<span <span
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full" class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
> >
@@ -559,6 +559,17 @@ describe("screen snapshots", () => {
it("Dashboard", async () => { it("Dashboard", async () => {
await snapshot(<Dashboard />, "/"); await snapshot(<Dashboard />, "/");
}); });
it("Dashboard deep link opens the Workflow sub-tab with the workflow analytics", async () => {
localStorage.removeItem("dashboard_tab");
const { container } = render(
<MemoryRouter initialEntries={["/?tab=analytics&sub=workflow"]}>
<Dashboard />
</MemoryRouter>
);
await settle();
const headings = [...container.querySelectorAll("h2")].map((h) => h.textContent);
expect(headings).toContain(i18n.t("workflows:title"));
});
it("Sessions", async () => { it("Sessions", async () => {
await snapshot(<Sessions />, "/sessions"); await snapshot(<Sessions />, "/sessions");
}); });
+1 -1
View File
@@ -2120,7 +2120,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view. **PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the Dashboard's Analytics tab, and the Workflows page automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`. Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
--- ---
+2 -2
View File
@@ -815,7 +815,7 @@ under `agenthub feature activate`: the backwards declaration prunes the records
ahead of it, so the previous pass leaves nothing behind to count, while a ahead of it, so the previous pass leaves nothing behind to count, while a
feature switch archives them first. Neither ever counted *passes* — one workspace row feature switch archives them first. Neither ever counted *passes* — one workspace row
contributes at most one record per node, however many times it ran — so this contributes at most one record per node, however many times it ran — so this
narrows the history, it does not skew a rate. The Workflows page renders this as one bar per node (see narrows the history, it does not skew a rate. Dashboard → Analytics → Workflow renders this as one bar per node (see
`client/src/components/workflows/LanePipelinePanel.tsx`). `client/src/components/workflows/LanePipelinePanel.tsx`).
**A new run is opened by a DECLARATION, never by a detection alone.** `runs` is **A new run is opened by a DECLARATION, never by a detection alone.** `runs` is
@@ -1042,7 +1042,7 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.agenthub/pipelin
- **label** (string, optional): display name in the UI; defaults to `id` if omitted - **label** (string, optional): display name in the UI; defaults to `id` if omitted
- **icon** (string, optional): unicode emoji or single character shown before the stage name - **icon** (string, optional): unicode emoji or single character shown before the stage name
- **gate** (boolean, optional): `true` if this stage blocks unless explicitly passed; defaults to `false` - **gate** (boolean, optional): `true` if this stage blocks unless explicitly passed; defaults to `false`
- **command** (string, optional): the one command that drives THIS stage — a slash command (`"/my-training-skill"`) or a shell one (`"agenthub lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; use `"agenthub stage <id>"` for a node whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a workspace card and as copyable text per node on the Workflows page, and run by `agenthub lanes node <id>` (below); **never executed by the dashboard**, same contract as `skill`. - **command** (string, optional): the one command that drives THIS stage — a slash command (`"/my-training-skill"`) or a shell one (`"agenthub lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; use `"agenthub stage <id>"` for a node whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a workspace card and as copyable text per node in Dashboard → Analytics → Workflow, and run by `agenthub lanes node <id>` (below); **never executed by the dashboard**, same contract as `skill`.
- **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `agenthub stage training`). An alias may also name a **sub-state** — *why* the workspace sits on a node — without adding a node to the map. Two constraints: no alias may collide with another node's id or alias (`phaseIdx` takes the FIRST match, so a duplicate silently resolves a declaration onto the wrong node), and all aliases of one node share one record slot (`stageRecords` resolves each declared key onto its node, and the canonical id's record wins), so a sub-state that needs its own `--evidence` has to be a real node. - **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `agenthub stage training`). An alias may also name a **sub-state** — *why* the workspace sits on a node — without adding a node to the map. Two constraints: no alias may collide with another node's id or alias (`phaseIdx` takes the FIRST match, so a duplicate silently resolves a declaration onto the wrong node), and all aliases of one node share one record slot (`stageRecords` resolves each declared key onto its node, and the canonical id's record wins), so a sub-state that needs its own `--evidence` has to be a real node.
- **detect** (array, optional): stage-detection rules for this node — see "Stage detection → Where the rules live". - **detect** (array, optional): stage-detection rules for this node — see "Stage detection → Where the rules live".