diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bab50e09..5b1c53f3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -334,7 +334,7 @@ graph TD | `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` | | `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes | | `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint | -| `routes/workflows.js` | Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts `?status=active\|completed` query parameter to filter all data by session status. Per-session drill-in endpoint with agent tree, tool timeline, and event details | +| `routes/workflows.js` | Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts `?status=active\|completed` query parameter to filter all data by session status. `GET /api/workflows/skill-usage` reports per-skill and per-plugin call counts, counting `PreToolUse` rows only so a call is not double-counted against its `PostToolUse` twin. Per-session drill-in endpoint with agent tree, tool timeline, and event details | | `lib/transcript-cache.js` | Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between `hooks.js` (token extraction on every event) and the periodic compaction scanner (`index.js`). Extracts tokens, compaction entries, API errors (`isApiErrorMessage` + raw error responses), turn durations (`system` subtype `turn_duration`), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript `[Request interrupted by user]` entry / `interruptedMessageId` field — surfaced as `pendingInterrupt`, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) and `ai-title` (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads — plus the session's **first user prompt** (`firstUserMessage`: tool-result, meta/caveat, slash-command plumbing, compact-summary, and interrupt entries skipped; whitespace-collapsed, capped at 500 chars; first value wins across incremental reads), used as a fallback descriptor for placeholder-named sessions/agents. Uses `(path, mtime, size)` cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores **only** `{mtimeMs, size, bytesRead, result}` — the previous shape that duplicated every growable array at both the top level and inside `result` is gone, halving steady-state memory per entry. Per-entry growable arrays (`turnDurations`, `errors`, `compaction.entries`, `usageExtras.*`) are bounded to `TRANSCRIPT_CACHE_MAX_ARRAY_LEN` (default `1000`, tail-kept) — older items remain in the `events` table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches `2 * MAX_ARRAY_LEN`, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. **Chunked sync byte-stream reader** (`_streamRange`, 4 MiB chunks split on `0x0A` bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous `readFileSync("utf8")` so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`. Both full and incremental reads share the same line-level state machine (`_initParseState` / `_consumeLine` / `_finalizeState`). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup | | `lib/session-liveness.js` | Process-liveness probe for the watchdog's dead-session reap. `probeLiveCwds()` enumerates running `claude` CLI processes (`ps -Ao pid=,args=`, then `lsof -a -p -d cwd -Fn` on macOS or `/proc//cwd` on Linux) and returns the set of their working directories; `isClaudeCommand()` matches the bare binary and `node`/`bun`-launched shims while rejecting lookalikes (`claude-mem`, `Claude.app`). Fail-safe by contract: returns `available: false` (callers must change nothing) on Windows, inside containers (reuses `isInsideContainer` from `scripts/install-hooks.js` — host processes are invisible there, so an empty list would lie), on `ps`/`lsof` failure, or when `DASHBOARD_LIVENESS_PROBE=0` | | `bin/ccam.js` | Dependency-free umbrella CLI (`ccam `) exposing the full dashboard surface in the terminal: monitoring (health / stats / kanban / tail via short-interval event polling), data browsing (sessions, per-session detail with an indented agent tree + cost + recent events, agents, events), insights (analytics, workflow intelligence, dynamic Workflow-tool runs, per-model cost), alerts + rules + webhook test probes, pricing CRUD, imports (rescan / scan-path), and administration (doctor, info, export, cleanup, reinstall-hooks, clear-data). Linked globally by `npm run setup` via a fail-soft `npm link` (`link-cli` script). Discovers the live server through `server/lib/server-info.js` (`~/.claude/.agent-dashboard.json`, PID-liveness-checked) with `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` env overrides and a 4820 fallback; renders a full terminal UI — box-drawn width-fitted tables with right-aligned numeric columns, status icons, inline bar charts (stats / analytics / cost), `├─`/`└─` agent trees, and a TTY spinner for `start` — whose ANSI styling degrades to plain text when piped (`--no-color` / `NO_COLOR` / `FORCE_COLOR` / `CCAM_COLOR` respected); the one destructive command (`clear-data`) refuses to run without `--yes`. Server lifecycle: `ccam status` shows a ●/○ up/down indicator and `ccam start` boots a detached production server (waits for /api/health, logs to `data/ccam-server.log`). `ccam repl` (aliases `shell` / `i`) opens an interactive shell — a readline prompt with tab-completion (commands / subcommands / flags), persisted arrow-key history (`data/.ccam_repl_history`), and a live server-status prompt; each entered line runs as a short-lived child `ccam` process (via `runCommand` dispatch), so an offline refusal or a blocking `tail` cannot take the shell down, and piped input runs each line in order then exits at EOF. When the server is down, read-only commands (sessions / session / agents / events / kanban / stats / pricing list / alerts list / rules / export / doctor) fall back to direct SQLite reads of `data/dashboard.db` under a ⚠ Offline-mode banner — the connection is opened without SQLite readonly mode so a live WAL stays visible, and is SELECT-only by construction — while server-only commands (tail, analytics, workflows, runs, cost, mutations) refuse with the specific reason | @@ -354,7 +354,7 @@ graph TD | `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `ccam-lane-`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live | | `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `ccam lanes shell` CLI, etc.), all synced live | | `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) | -| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.ccam/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. | +| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.ccam/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. | | `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation | | `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and CCAM, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_ + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent | | `lib/lane-profile.js` | The stack seam. Resolves `/.ccam/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash ` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/.log` and that broadcast. Hook names come from a fixed allowlist, never from a request | @@ -455,7 +455,7 @@ graph TD DETAIL --> CONV["ConversationView
(MarkdownContent + CodeBlock
+ ToolCallBlock per-tool styling)"] DETAIL --> TL["Event Timeline"] ACTIVITY --> FEED["Streaming Event List
(click row → expand payload;
Session btn → session detail)"] - WORKFLOWS_P --> WFC["Lane pipelines +
4 analytics components
(workflows/ directory)"] + WORKFLOWS_P --> WFC["Own-pipeline panels +
D3 analytics components
(workflows/ directory)"] style APP fill:#6366f1,stroke:#818cf8,color:#fff style LAYOUT fill:#1a1a28,stroke:#2a2a3d,color:#e4e4ed @@ -656,21 +656,33 @@ graph TD WS_D --> WF WF --> S1["WorkflowStats
Summary cards"] - WF --> S2["LanePipelinePanel
Lane strip + selected lane's
PipelineMap (read-only)"] - WF --> S3["WorkflowRunsPanel
Workflow-tool runs
(on-disk journals)"] - WF --> S4["OrchestrationDAG
Horizontal DAG —
Sessions → Main → Subagents → Outcomes"] - WF --> S5["ToolExecutionFlow
d3-sankey tool transitions"] - WF --> S6["SessionDrillIn
Searchable session explorer
(3 tabs: tree / timeline / events)"] + WF --> S2["LanePipelinePanel
Per-node pass rates
(lanes + archived features)"] + WF --> S3["SkillUsagePanel
Skill + plugin call counts"] + WF --> S4["WorkflowRunsPanel
Workflow-tool runs
(on-disk journals)"] + WF --> S5["OrchestrationDAG
Horizontal DAG —
Sessions → Main → Subagents → Outcomes"] + WF --> S6["ToolExecutionFlow
d3-sankey tool transitions"] + WF --> S7["WorkflowPatterns
Auto-detected sequences"] + WF --> S8["ModelDelegationFlow
Model → agent routing"] + WF --> S9["ErrorPropagationMap
React horizontal bars +
API/session error support"] + WF --> S10["SessionComplexityScatter
D3 bubble chart"] + WF --> S11["CompactionImpact
Token compression analysis"] + WF --> S12["SessionDrillIn
Searchable session explorer
(3 tabs: tree / timeline / events)"] classDef root fill:#6366f1,stroke:#818cf8,color:#fff ``` | Component | Visualization | D3 Feature | | --- | --- | --- | -| `LanePipelinePanel` | This dashboard's own lanes: a horizontally scrolling lane strip and the selected lane's pipeline map | Plain React; reuses `lanes/LaneStripCard` + `lanes/PipelineMap`, polls `GET /api/lanes` and debounces websocket events. Read-only — every lane action stays on the Workspace page | +| `LanePipelinePanel` | Pass rate per pipeline node across every lane and archived feature | Plain React stacked bars from `GET /api/lanes/pipeline-stats`, scaled against the busiest node. Green/amber/red split evidence-backed passes from bare claims and failures; read-only, the live per-lane view stays on Workspace | +| `SkillUsagePanel` | Which skills and plugins the sessions actually use | Plain React ranked bars from `GET /api/workflows/skill-usage`; plugin chips sum skill calls and the plugin's own MCP tool calls | | `WorkflowRunsPanel` | Workflow-tool runs reconstructed from on-disk journals | Per-run phase chips, per-agent table, expandable results | | `OrchestrationDAG` | Horizontal DAG of aggregate spawning patterns | Custom DAG layout, capped at top 7 subagent types with overflow node | | `ToolExecutionFlow` | Tool-to-tool transition Sankey diagram | `d3-sankey` | +| `WorkflowPatterns` | Common orchestration sequences | Pattern detection from event data; clicking a row expands the full step chain, a stats grid, a deterministic narrative and a suggestion bucket, all i18n-driven (`workflows.patterns.detail.*`) | +| `ModelDelegationFlow` | Model routing through agent hierarchies | Hierarchical layout | +| `ErrorPropagationMap` | Error clustering by hierarchy depth with API/session event errors | Pure React horizontal bars, `eventErrors` support for API and session-level errors | +| `SessionComplexityScatter` | Duration vs agents vs tokens | D3 bubble/scatter chart | +| `CompactionImpact` | Sessions-by-compaction-count histogram | D3 histogram with axis titles, stat tiles and React-managed hover tooltips | | `SessionDrillIn` | Per-session agent tree, tool timeline, events | Searchable dropdown with pagination, 3 tabs | **Cross-filtering:** Clicking nodes in the OrchestrationDAG filters data in other sections. **JSON export:** All workflow data can be exported as JSON from the page header. diff --git a/client/README.md b/client/README.md index dc2c365c..d57f82f7 100644 --- a/client/README.md +++ b/client/README.md @@ -178,7 +178,7 @@ client/ │ │ ├── Sidebar.tsx │ │ ├── Layout.tsx │ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection) -│ │ └── workflows/ # Workflows page panels: lane pipelines + D3 analytics (6 files) +│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (12 files) │ │ │ ├── pages/ # Route pages │ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs diff --git a/client/src/components/workflows/CompactionImpact.tsx b/client/src/components/workflows/CompactionImpact.tsx new file mode 100644 index 00000000..a2d0af1e --- /dev/null +++ b/client/src/components/workflows/CompactionImpact.tsx @@ -0,0 +1,424 @@ +/** + * @file CompactionImpact.tsx + * @description Visualizes how context compaction is spread across sessions. + * Compaction is when Claude Code compresses older conversation history into a + * summary once a session's context window fills up. This panel surfaces the + * at-a-glance stats (total events, sessions affected, average and peak per + * session) and a histogram answering "how many sessions compacted N times?" + * so the distribution is legible regardless of how many sessions exist. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Internal dependencies + * - `../../lib/types` + * + * ## Public surface + * - `CompactionImpactProps` — exported API; see TSDoc on the symbol for behavior. + * - `CompactionImpact` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **CompactionImpactProps** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **CompactionImpact** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useRef, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as d3 from "d3"; +import type { CompactionImpactData } from "../../lib/types"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function fmtTokens(n: number): string { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +/** Roll the per-session list into a histogram: for each compaction count k + * (1..peak), how many sessions compacted exactly k times. */ +function toHistogram(perSession: CompactionImpactData["perSession"]): Array<{ + count: number; + sessions: number; +}> { + const peak = perSession.reduce((m, s) => Math.max(m, s.compactions), 0); + const buckets: Array<{ count: number; sessions: number }> = []; + for (let k = 1; k <= peak; k++) { + buckets.push({ count: k, sessions: perSession.filter((s) => s.compactions === k).length }); + } + return buckets; +} + +// ── Chart constants ─────────────────────────────────────────────────────────── + +const MARGIN = { top: 18, right: 16, bottom: 46, left: 48 }; +const CHART_HEIGHT = 200; + +// ── D3 renderer ─────────────────────────────────────────────────────────────── + +interface HistogramBucket { + count: number; + sessions: number; +} + +interface HistogramOpts { + x: string; + y: string; + onHover: (e: MouseEvent, d: HistogramBucket) => void; + onMove: (e: MouseEvent) => void; + onLeave: () => void; +} + +function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: HistogramOpts): void { + const container = svg.parentElement; + const width = container ? container.clientWidth : 400; + const innerW = width - MARGIN.left - MARGIN.right; + const innerH = CHART_HEIGHT - MARGIN.top - MARGIN.bottom; + + const root = d3.select(svg); + root.selectAll("*").remove(); + root.attr("viewBox", `0 0 ${width} ${CHART_HEIGHT}`).attr("preserveAspectRatio", "xMidYMid meet"); + + const defs = root.append("defs"); + const grad = defs + .append("linearGradient") + .attr("id", "compact-bar-grad") + .attr("x1", "0%") + .attr("y1", "0%") + .attr("x2", "0%") + .attr("y2", "100%"); + grad.append("stop").attr("offset", "0%").attr("stop-color", "#818cf8"); + grad + .append("stop") + .attr("offset", "100%") + .attr("stop-color", "#3730a3") + .attr("stop-opacity", 0.7); + + const g = root.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`); + + const maxSessions = d3.max(histo, (d) => d.sessions) ?? 1; + + const xScale = d3 + .scaleBand() + .domain(histo.map((d) => d.count)) + .range([0, innerW]) + .padding(histo.length > 12 ? 0.18 : 0.34); + + const yScale = d3.scaleLinear().domain([0, maxSessions]).nice().range([innerH, 0]); + + // Horizontal grid lines (integer session counts) + const yTicks = yScale.ticks(Math.min(4, maxSessions)).filter((d) => Number.isInteger(d)); + g.selectAll(".grid-line") + .data(yTicks) + .join("line") + .attr("class", "grid-line") + .attr("x1", 0) + .attr("x2", innerW) + .attr("y1", (d) => yScale(d)) + .attr("y2", (d) => yScale(d)) + .attr("stroke", "#2a2a3d") + .attr("stroke-width", 1); + + // Y axis (sessions) + g.append("g") + .call( + d3 + .axisLeft(yScale) + .tickValues(yTicks) + .tickSize(0) + .tickPadding(8) + .tickFormat((d) => String(d)) + ) + .call((ax) => ax.select(".domain").remove()) + .selectAll("text") + .attr("fill", "#6b7280") + .attr("font-size", 10) + .attr("font-family", "Inter, sans-serif"); + + // X axis (compactions per session - one tick per bucket) + g.append("g") + .attr("transform", `translate(0,${innerH})`) + .call(d3.axisBottom(xScale).tickSize(0).tickPadding(8)) + .call((ax) => ax.select(".domain").remove()) + .selectAll("text") + .attr("fill", "#9ca3af") + .attr("font-size", 10) + .attr("font-family", "Inter, sans-serif"); + + // Axis titles + g.append("text") + .attr("x", innerW / 2) + .attr("y", innerH + 38) + .attr("text-anchor", "middle") + .attr("fill", "#6b7280") + .attr("font-size", 10) + .attr("font-weight", 500) + .attr("font-family", "Inter, sans-serif") + .text(opts.x); + + g.append("text") + .attr("transform", "rotate(-90)") + .attr("x", -innerH / 2) + .attr("y", -38) + .attr("text-anchor", "middle") + .attr("fill", "#6b7280") + .attr("font-size", 10) + .attr("font-weight", 500) + .attr("font-family", "Inter, sans-serif") + .text(opts.y); + + // Bars + count labels + rich hover tooltip (full-height hit-area so every + // column - including empty buckets - responds, matching the other charts). + histo.forEach((d) => { + const bx = xScale(d.count); + if (bx === undefined) return; + const bw = xScale.bandwidth(); + const by = yScale(d.sessions); + const barH = innerH - by; + + const bg = g.append("g"); + let bar: d3.Selection | null = null; + + if (d.sessions > 0) { + bar = bg + .append("rect") + .attr("x", bx) + .attr("y", by) + .attr("width", bw) + .attr("height", barH) + .attr("rx", Math.min(4, bw / 2)) + .attr("fill", "url(#compact-bar-grad)") + .style("transition", "fill 120ms ease"); + + bg.append("text") + .attr("x", bx + bw / 2) + .attr("y", by - 5) + .attr("text-anchor", "middle") + .attr("fill", "#a5b4fc") + .attr("font-size", 10) + .attr("font-weight", "600") + .attr("font-family", "Inter, sans-serif") + .attr("pointer-events", "none") + .text(d.sessions); + } else { + // Empty bucket: faint baseline tick so the gap reads as "zero", not missing + bg.append("rect") + .attr("x", bx) + .attr("y", innerH - 1) + .attr("width", bw) + .attr("height", 1) + .attr("fill", "#2a2a3d"); + } + + // Transparent, full-height hover target on top of the bar. + bg.append("rect") + .attr("x", bx) + .attr("y", 0) + .attr("width", bw) + .attr("height", innerH) + .attr("fill", "transparent") + .style("cursor", "pointer") + .on("mouseenter", (event: MouseEvent) => { + if (bar) bar.attr("fill", "#a5b4fc"); + opts.onHover(event, d); + }) + .on("mousemove", (event: MouseEvent) => opts.onMove(event)) + .on("mouseleave", () => { + if (bar) bar.attr("fill", "url(#compact-bar-grad)"); + opts.onLeave(); + }); + }); +} + +// ── Stat box ────────────────────────────────────────────────────────────────── + +interface StatBoxProps { + label: string; + value: string; + sub?: string; + accent?: string; +} + +function StatBox({ label, value, sub, accent = "text-accent" }: StatBoxProps) { + return ( +
+ {value} + + {label} + + {sub && {sub}} +
+ ); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export interface CompactionImpactProps { + data: CompactionImpactData; +} + +export function CompactionImpact({ data }: CompactionImpactProps) { + const { t } = useTranslation("workflows"); + const svgRef = useRef(null); + const [tip, setTip] = useState<{ x: number; y: number; title: string; detail: string } | null>( + null + ); + + const hasData = data.totalCompactions > 0; + const affected = data.sessionsWithCompactions; + const sessionPct = data.totalSessions > 0 ? Math.round((affected / data.totalSessions) * 100) : 0; + const avgPerSession = affected > 0 ? data.totalCompactions / affected : 0; + const peak = data.perSession.reduce((m, s) => Math.max(m, s.compactions), 0); + + useEffect(() => { + if (!svgRef.current || !hasData) return; + const histo = toHistogram(data.perSession); + const affectedN = data.sessionsWithCompactions; + renderHistogram(svgRef.current, histo, { + x: t("compaction.xAxis"), + y: t("compaction.yAxis"), + onHover: (e, d) => { + const pct = affectedN > 0 ? Math.round((d.sessions / affectedN) * 100) : 0; + setTip({ + x: e.clientX, + y: e.clientY, + title: t("compaction.tipTitle", { count: d.count }), + detail: t("compaction.tipDetail", { sessions: d.sessions, pct }), + }); + }, + onMove: (e) => setTip((p) => (p ? { ...p, x: e.clientX, y: e.clientY } : p)), + onLeave: () => setTip(null), + }); + }, [data, hasData, t]); + + if (!hasData) { + return ( +
+ + {t("compaction.noData")} +
+ ); + } + + return ( +
+ {/* What compaction is - one line so the numbers below make sense */} +

{t("compaction.help")}

+ + {/* Stat tiles */} +
+ + + + +
+ + {/* Histogram: sessions by compaction count */} +
+

+ {t("compaction.distribution")} +

+ +
+ + {/* Plain-English summary + (when present) tokens freed */} +

+ {t("compaction.summary", { + affected: affected.toLocaleString(), + total: data.totalSessions.toLocaleString(), + pct: sessionPct, + })} + {data.tokensRecovered > 0 && ( + <> {t("compaction.tokensFreed", { tokens: fmtTokens(data.tokensRecovered) })} + )} +

+ + {/* Hover tooltip (matches the app's other chart tooltips) */} + {tip && ( +
window.innerWidth - 220 ? tip.x - 14 : tip.x + 14, + top: tip.y - 10, + transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined, + }} + > +
{tip.title}
+
{tip.detail}
+
+ )} +
+ ); +} diff --git a/client/src/components/workflows/ErrorPropagationMap.tsx b/client/src/components/workflows/ErrorPropagationMap.tsx new file mode 100644 index 00000000..9f713d84 --- /dev/null +++ b/client/src/components/workflows/ErrorPropagationMap.tsx @@ -0,0 +1,270 @@ +/** + * @file ErrorPropagationMap.tsx + * @description A React component that visualizes error propagation across agent hierarchies in a workflow system. It displays the distribution of errors by hierarchy depth, identifies error-prone agent types, and highlights API and session errors. The component uses horizontal bars to represent error counts at different depths and types, providing an intuitive overview of where errors are occurring within the agent structure. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Internal dependencies + * - `../../lib/types` + * + * ## Public surface + * - `ErrorPropagationMapProps` — exported API; see TSDoc on the symbol for behavior. + * - `ErrorPropagationMap` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **ErrorPropagationMapProps** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **ErrorPropagationMap** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { ErrorPropagationData } from "../../lib/types"; + +const DEPTH_COLORS = ["#ef4444", "#f97316", "#eab308", "#a855f7"]; + +// ── Component ───────────────────────────────────────────────────────────────── + +export interface ErrorPropagationMapProps { + data: ErrorPropagationData; +} + +export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) { + const { t } = useTranslation("workflows"); + const [hoveredDepth, setHoveredDepth] = useState(null); + + function depthLabel(depth: number): string { + const keys = [ + t("errorPropagation.depthLabels.sessionMain"), + t("errorPropagation.depthLabels.directSubagent"), + t("errorPropagation.depthLabels.nested"), + t("errorPropagation.depthLabels.deep"), + ]; + return keys[depth] ?? `${t("common:depth")} ${depth}`; + } + + const hasErrors = + data.byDepth.some((d) => d.count > 0) || + data.byType.some((t) => t.count > 0) || + (data.eventErrors && data.eventErrors.length > 0) || + data.sessionsWithErrors > 0; + + if (!hasErrors) { + return ( +
+
+ + + + +
+ + {t("errorPropagation.noErrors")} + + {t("errorPropagation.allSuccess")} +
+ ); + } + + const errorRatePct = data.errorRate; + const totalErrors = data.byDepth.reduce((s, d) => s + d.count, 0); + const maxDepthCount = Math.max(...data.byDepth.map((d) => d.count), 1); + const topTypes = [...data.byType].sort((a, b) => b.count - a.count).slice(0, 6); + const hasDepthData = data.byDepth.some((d) => d.count > 0); + + return ( +
+ {/* Error rate summary bar */} +
+
+ + {errorRatePct}% + +
+
+

+ {t("errorPropagation.sessionsErrorSummary", { + errorSessions: data.sessionsWithErrors, + totalSessions: data.totalSessions, + })} +

+

+ {totalErrors > 0 + ? `${totalErrors}${t("errorPropagation.agentErrors")}` + : t("errorPropagation.sessionErrorsOnly")} +

+
+
+ + {/* Errors by depth - horizontal bars */} + {hasDepthData && ( +
+

+ {t("errorPropagation.errorsByDepth")} +

+
+ {data.byDepth + .filter((d) => d.count > 0) + .map((d) => { + const pct = (d.count / maxDepthCount) * 100; + const color = DEPTH_COLORS[d.depth] ?? DEPTH_COLORS[DEPTH_COLORS.length - 1]; + const isHovered = hoveredDepth === d.depth; + return ( +
setHoveredDepth(d.depth)} + onMouseLeave={() => setHoveredDepth(null)} + > + + {depthLabel(d.depth)} + +
+
+
+ + {d.count} + +
+ ); + })} +
+
+ )} + + {/* Error-prone agent types */} + {topTypes.length > 0 && ( +
+

+ {t("errorPropagation.errorProneTypes")} +

+
+ {topTypes.map((t, i) => { + const maxCount = topTypes[0]?.count ?? 1; + const pct = (t.count / maxCount) * 100; + return ( +
+ + + {t.subagent_type} + +
+
+
+ + {t.count} + +
+ ); + })} +
+
+ )} + + {/* API & session errors */} + {data.eventErrors && data.eventErrors.length > 0 && ( +
+

+ {t("errorPropagation.apiSessionErrors")} +

+
+ {data.eventErrors.map((e) => ( +
+ + + + + + + {e.summary} + + + {e.count}x + +
+ ))} +
+
+ )} +
+ ); +} diff --git a/client/src/components/workflows/LanePipelinePanel.tsx b/client/src/components/workflows/LanePipelinePanel.tsx index 0559498a..97da2187 100644 --- a/client/src/components/workflows/LanePipelinePanel.tsx +++ b/client/src/components/workflows/LanePipelinePanel.tsx @@ -1,41 +1,41 @@ /** * @file LanePipelinePanel.tsx - * @description Read-only view of this dashboard's OWN pipelines on the Workflows - * page: a horizontally scrolling strip of lane tiles, and the selected lane's - * pipeline map underneath. It reuses the Workspace's `LaneStripCard` and - * `PipelineMap` so a stage reads identically on both pages. Read-only on - * purpose — the console-never-writes-a-stage boundary applies here too, so this - * panel only observes; every lane action stays on the Workspace page. + * @description How often each node of this dashboard's OWN pipelines actually + * gets passed, aggregated over every lane and every archived feature by + * `GET /api/lanes/pipeline-stats`. One row per node: a bar split into passed + * with evidence / passed without / failed, plus the raw counts, so a stage that + * everyone claims but nobody proves is visible at a glance. Read-only — the + * live per-lane view and every lane action stay on the Workspace page. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState } from "react"; -import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { ExternalLink } from "lucide-react"; import { api } from "../../lib/api"; import { eventBus } from "../../lib/eventBus"; -import type { Lane } from "../../lib/types"; -import LaneStripCard from "../lanes/LaneStripCard"; -import PipelineMap from "../lanes/PipelineMap"; +import type { PipelineStats } from "../../lib/types"; + +/** Percentage of `n` out of `total`, 0 when nothing was recorded. */ +function pct(n: number, total: number): number { + return total > 0 ? (n / total) * 100 : 0; +} /** - * Lane strip plus the selected lane's pipeline map. - * @returns The panel element; an empty-state hint when no lane exists yet. + * Per-node pass statistics for the selected pipeline template. + * @returns The panel element, or an empty-state hint when nothing was recorded. */ export function LanePipelinePanel() { - const { t } = useTranslation(["workflows", "lanes"]); - const [lanes, setLanes] = useState([]); - const [selectedId, setSelectedId] = useState(null); + const { t } = useTranslation("workflows"); + const [data, setData] = useState(null); + const [selectedId, setSelectedId] = useState(null); const [error, setError] = useState(null); const refresh = useCallback(async () => { try { - const r = await api.lanes.list(); - setLanes(r.lanes); + setData(await api.lanes.pipelineStats()); setError(null); } catch (err) { - setError(err instanceof Error ? err.message : t("workflows:failedLoad")); + setError(err instanceof Error ? err.message : t("failedLoad")); } }, [t]); @@ -43,7 +43,7 @@ export function LanePipelinePanel() { void refresh(); }, [refresh]); - // Lanes move as hooks land; debounce so a burst of events costs one fetch. + // Stages move as agents declare them; debounce so a burst costs one fetch. useEffect(() => { let timer: ReturnType; const unsub = eventBus.subscribe(() => { @@ -56,53 +56,106 @@ export function LanePipelinePanel() { }; }, [refresh]); - // Follow the list rather than pinning an id: a lane can be removed elsewhere. - const selected = lanes.find((l) => l.id === selectedId) ?? lanes[0] ?? null; - if (error) return

{error}

; + if (!data) return null; - if (!lanes.length) { + // Follow the list rather than pinning an id: templates can come and go. + const selected = data.pipelines.find((p) => p.id === selectedId) ?? data.pipelines[0] ?? null; + + if (!selected) { return (

- {t("lanes:emptyState")} ccam lanes add --cwd $(pwd) + {t("lanes.empty")} ccam lanes add --cwd $(pwd)

); } + const maxEntered = Math.max(...selected.nodes.map((n) => n.entered), 1); + return (
-
- {lanes.map((l) => ( - setSelectedId(l.id)} - /> - ))} +
+ {data.pipelines.length > 1 && ( + + )} + {t("lanes.runs", { count: selected.runs })} +
+ + + +
- {selected && ( -
-
- - {t("lanes:laneHeader", { - id: selected.id, - title: selected.title || selected.cwd, - pipeline: selected.pipeline_name, - })} - - + {selected.nodes.map((n) => { + const rate = pct(n.passed, n.entered); + return ( +
- {t("workflows:lanes.openWorkspace")} - - -
- -
- )} + + {n.icon} + {n.label} + + + {/* Bars are scaled against the busiest node, not against each + node's own total, so a node reached twice never looks as + travelled as one reached fifty times. */} +
+
+
+
+
+ + + {n.entered === 0 ? ( + t("lanes.never") + ) : ( + <> + {n.passed}/{n.entered} + ({Math.round(rate)}%) + + )} + +
+ ); + })} +
); } + +function LegendSwatch({ className, label }: { className: string; label: string }) { + return ( + + + {label} + + ); +} diff --git a/client/src/components/workflows/ModelDelegationFlow.tsx b/client/src/components/workflows/ModelDelegationFlow.tsx new file mode 100644 index 00000000..ba128573 --- /dev/null +++ b/client/src/components/workflows/ModelDelegationFlow.tsx @@ -0,0 +1,532 @@ +/** + * @file ModelDelegationFlow.tsx + * @description Defines the ModelDelegationFlow React component that visualizes the relationships between main models and subagent models in a flow diagram using D3.js. The component takes model delegation data as input and renders an SVG diagram that shows how different models are connected based on their usage in agents and sessions. It categorizes models into families (opus, sonnet, haiku, other) for color-coding and provides a clear visual representation of model delegation patterns. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Internal dependencies + * - `../../lib/types` + * - `../../lib/format` + * + * ## Public surface + * - `ModelDelegationFlowProps` — exported API; see TSDoc on the symbol for behavior. + * - `ModelDelegationFlow` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **ModelDelegationFlowProps** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **ModelDelegationFlow** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useRef, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import * as d3 from "d3"; +import type { ModelDelegationData } from "../../lib/types"; +import { formatModelName } from "../../lib/format"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function modelFamily(name: string): "opus" | "sonnet" | "haiku" | "other" { + const lower = name.toLowerCase(); + if (lower.includes("opus")) return "opus"; + if (lower.includes("sonnet")) return "sonnet"; + if (lower.includes("haiku")) return "haiku"; + return "other"; +} + +function fmtTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return String(n); +} + +// Model formatting is handled by the shared formatModelName utility. + +// ── Color palette per model family ─────────────────────────────────────────── + +const FAMILY_COLORS = { + opus: { + grad: ["#7c3aed", "#a855f7"] as [string, string], + stroke: "#a855f7", + text: "#e9d5ff", + badge: "rgba(168,85,247,0.15)", + }, + sonnet: { + grad: ["#1d4ed8", "#3b82f6"] as [string, string], + stroke: "#3b82f6", + text: "#bfdbfe", + badge: "rgba(59,130,246,0.15)", + }, + haiku: { + grad: ["#065f46", "#10b981"] as [string, string], + stroke: "#10b981", + text: "#a7f3d0", + badge: "rgba(16,185,129,0.15)", + }, + other: { + grad: ["#374151", "#6b7280"] as [string, string], + stroke: "#6b7280", + text: "#d1d5db", + badge: "rgba(107,114,128,0.15)", + }, +} as const; + +// ── Types used internally ───────────────────────────────────────────────────── + +interface NodeDatum { + id: string; + label: string; + family: "opus" | "sonnet" | "haiku" | "other"; + agentCount: number; + sessionCount: number; + totalTokens: number; + side: "main" | "sub"; + x: number; + y: number; +} + +interface EdgeDatum { + sourceId: string; + targetId: string; +} + +type ShowTipFn = (node: NodeDatum, anchor: SVGGraphicsElement) => void; +type HideTipFn = () => void; + +// ── D3 chart renderer ───────────────────────────────────────────────────────── + +const NODE_W = 160; +const NODE_H = 80; +const NODE_RX = 10; +const COL_GAP = 200; +const ROW_GAP = 108; +const PADDING = { top: 40, left: 24, right: 24, bottom: 24 }; + +function renderFlow( + svg: SVGSVGElement, + mainNodes: NodeDatum[], + subNodes: NodeDatum[], + edges: EdgeDatum[], + t: (key: string, options?: Record) => string, + showTip: ShowTipFn, + hideTip: HideTipFn +): void { + const allNodes = [...mainNodes, ...subNodes]; + + const totalRows = Math.max(mainNodes.length, subNodes.length); + const chartH = totalRows * ROW_GAP + PADDING.top + PADDING.bottom; + const chartW = NODE_W * 2 + COL_GAP + PADDING.left + PADDING.right; + + const root = d3.select(svg); + root.selectAll("*").remove(); + root.attr("viewBox", `0 0 ${chartW} ${chartH}`).attr("preserveAspectRatio", "xMidYMid meet"); + + const defs = root.append("defs"); + + // Gradient defs per family + (["opus", "sonnet", "haiku", "other"] as const).forEach((fam) => { + const colors = FAMILY_COLORS[fam]; + const grad = defs + .append("linearGradient") + .attr("id", `flow-grad-${fam}`) + .attr("x1", "0%") + .attr("y1", "0%") + .attr("x2", "100%") + .attr("y2", "100%"); + grad.append("stop").attr("offset", "0%").attr("stop-color", colors.grad[0]); + grad.append("stop").attr("offset", "100%").attr("stop-color", colors.grad[1]); + }); + + const g = root.append("g"); + + // Column labels + const labelY = PADDING.top - 16; + g.append("text") + .attr("x", PADDING.left + NODE_W / 2) + .attr("y", labelY) + .attr("text-anchor", "middle") + .attr("fill", "#6b7280") + .attr("font-size", 11) + .attr("font-family", "Inter, sans-serif") + .attr("letter-spacing", "0.08em") + .text(t("modelDelegation.mainModels")); + + g.append("text") + .attr("x", PADDING.left + NODE_W + COL_GAP + NODE_W / 2) + .attr("y", labelY) + .attr("text-anchor", "middle") + .attr("fill", "#6b7280") + .attr("font-size", 11) + .attr("font-family", "Inter, sans-serif") + .attr("letter-spacing", "0.08em") + .text(t("modelDelegation.subagentModels")); + + // Build lookup for node positions + const nodeMap = new Map(allNodes.map((n) => [n.id, n])); + + // Draw edges (cubic bezier curves) + edges.forEach(({ sourceId, targetId }) => { + const src = nodeMap.get(sourceId); + const tgt = nodeMap.get(targetId); + if (!src || !tgt) return; + + const x1 = src.x + NODE_W; + const y1 = src.y + NODE_H / 2; + const x2 = tgt.x; + const y2 = tgt.y + NODE_H / 2; + const cx = (x1 + x2) / 2; + + g.append("path") + .attr("d", `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`) + .attr("fill", "none") + .attr("stroke", "#2a2a3d") + .attr("stroke-width", 1.5) + .attr("opacity", 0.7); + }); + + // Draw nodes + allNodes.forEach((node) => { + const colors = FAMILY_COLORS[node.family]; + const ng = g + .append("g") + .attr("transform", `translate(${node.x},${node.y})`) + .style("cursor", "default") + .on("mouseenter", function () { + showTip(node, this as SVGGraphicsElement); + }) + .on("mouseleave", () => hideTip()); + + // Border glow rect (slightly larger) + ng.append("rect") + .attr("x", -1) + .attr("y", -1) + .attr("width", NODE_W + 2) + .attr("height", NODE_H + 2) + .attr("rx", NODE_RX + 1) + .attr("fill", "none") + .attr("stroke", colors.stroke) + .attr("stroke-width", 1) + .attr("opacity", 0.25); + + // Main rect with gradient + ng.append("rect") + .attr("width", NODE_W) + .attr("height", NODE_H) + .attr("rx", NODE_RX) + .attr("fill", `url(#flow-grad-${node.family})`) + .attr("fill-opacity", 0.18) + .attr("stroke", colors.stroke) + .attr("stroke-width", 1); + + // Model name + ng.append("text") + .attr("x", 12) + .attr("y", 22) + .attr("fill", colors.text) + .attr("font-size", 11) + .attr("font-weight", "600") + .attr("font-family", "Inter, sans-serif") + .text(formatModelName(node.label) ?? node.label); + + // Agent count pill + ng.append("rect") + .attr("x", 10) + .attr("y", 32) + .attr("width", 64) + .attr("height", 16) + .attr("rx", 4) + .attr("fill", colors.badge); + + ng.append("text") + .attr("x", 42) + .attr("y", 43.5) + .attr("text-anchor", "middle") + .attr("fill", colors.text) + .attr("font-size", 9.5) + .attr("font-family", "Inter, sans-serif") + .text(t("modelDelegation.agentsCount", { count: node.agentCount })); + + // Token count + if (node.totalTokens > 0) { + ng.append("text") + .attr("x", 12) + .attr("y", 66) + .attr("fill", "#6b7280") + .attr("font-size", 9.5) + .attr("font-family", "Inter, sans-serif") + .text(t("modelDelegation.tokensCount", { tokens: fmtTokens(node.totalTokens) })); + } + + // Session count (main nodes only) + if (node.side === "main" && node.sessionCount > 0) { + ng.append("text") + .attr("x", NODE_W - 10) + .attr("y", 66) + .attr("text-anchor", "end") + .attr("fill", "#6b7280") + .attr("font-size", 9.5) + .attr("font-family", "Inter, sans-serif") + .text(t("modelDelegation.sessionsCount", { count: node.sessionCount })); + } + }); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export interface ModelDelegationFlowProps { + data: ModelDelegationData; +} + +export function ModelDelegationFlow({ data }: ModelDelegationFlowProps) { + const { t } = useTranslation("workflows"); + const svgRef = useRef(null); + const containerRef = useRef(null); + const tipRef = useRef(null); + + const hasData = data.mainModels.length > 0 || data.subagentModels.length > 0; + const totalAgents = countTotalAgents(data); + + const hideTip = useCallback(() => { + const tip = tipRef.current; + if (tip) tip.style.opacity = "0"; + }, []); + + const showTip = useCallback( + (node: NodeDatum, anchor: SVGGraphicsElement) => { + const tip = tipRef.current; + if (!tip) return; + buildModelDelegationTooltip(tip, node, totalAgents, t); + const r = anchor.getBoundingClientRect(); + tip.style.opacity = "0"; + tip.style.display = "block"; + const tipW = tip.offsetWidth || 280; + const tipH = tip.offsetHeight || 160; + + const margin = 8; + let left = r.left + r.width / 2 - tipW / 2; + if (left < margin) left = margin; + if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin; + let top = r.top - tipH - 10; + if (top < margin) top = r.bottom + 10; + + tip.style.left = `${left}px`; + tip.style.top = `${top}px`; + tip.style.opacity = "1"; + }, + [totalAgents, t] + ); + + useEffect(() => { + if (!svgRef.current || !hasData) return; + + const tokenMap = new Map(); + data.tokensByModel.forEach(({ model, input_tokens, output_tokens }) => { + tokenMap.set(model, (tokenMap.get(model) ?? 0) + input_tokens + output_tokens); + }); + + const mainNodes: NodeDatum[] = data.mainModels.map((m, i) => ({ + id: `main-${m.model}`, + label: formatModelName(m.model) ?? m.model, + family: modelFamily(m.model), + agentCount: m.agent_count, + sessionCount: m.session_count, + totalTokens: tokenMap.get(m.model) ?? 0, + side: "main", + x: PADDING.left, + y: PADDING.top + i * ROW_GAP, + })); + + const subNodes: NodeDatum[] = data.subagentModels.map((m, i) => ({ + id: `sub-${m.model}`, + label: formatModelName(m.model) ?? m.model, + family: modelFamily(m.model), + agentCount: m.agent_count, + sessionCount: 0, + totalTokens: tokenMap.get(m.model) ?? 0, + side: "sub", + x: PADDING.left + NODE_W + COL_GAP, + y: PADDING.top + i * ROW_GAP, + })); + + // Connect all main models to all subagent models that share a family, or all if no match + const edges: EdgeDatum[] = []; + mainNodes.forEach((mn) => { + subNodes.forEach((sn) => { + edges.push({ sourceId: mn.id, targetId: sn.id }); + }); + }); + + renderFlow(svgRef.current, mainNodes, subNodes, edges, t, showTip, hideTip); + hideTip(); + }, [data, hasData, t, showTip, hideTip]); + + if (!hasData) { + return ( +
+ + {t("modelDelegation.noData")} +
+ ); + } + + return ( +
+ + + ); +} + +// ── Tooltip helpers ─────────────────────────────────────────────────────────── + +function countTotalAgents(data: ModelDelegationData): number { + const mainSum = data.mainModels.reduce((s, m) => s + m.agent_count, 0); + const subSum = data.subagentModels.reduce((s, m) => s + m.agent_count, 0); + return mainSum + subSum; +} + +type TFn = (key: string, options?: Record) => string; + +function describeFamily(family: NodeDatum["family"], t: TFn): string { + switch (family) { + case "opus": + return t("modelDelegation.tooltip.family.opus"); + case "sonnet": + return t("modelDelegation.tooltip.family.sonnet"); + case "haiku": + return t("modelDelegation.tooltip.family.haiku"); + case "other": + return t("modelDelegation.tooltip.family.other"); + } +} + +function buildModelDelegationTooltip( + el: HTMLDivElement, + node: NodeDatum, + totalAgents: number, + t: TFn +) { + while (el.firstChild) el.removeChild(el.firstChild); + + const sharePct = totalAgents > 0 ? `${((node.agentCount / totalAgents) * 100).toFixed(1)}%` : "-"; + const sideLabel = + node.side === "main" + ? t("modelDelegation.tooltip.mainModel") + : t("modelDelegation.tooltip.subagentModel"); + + const title = document.createElement("p"); + title.style.cssText = "font-size:12px;font-weight:600;color:#e2e8f0;margin:0"; + title.textContent = node.label; + el.appendChild(title); + + const subtitle = document.createElement("p"); + subtitle.style.cssText = + "font-size:10px;color:#64748b;margin:2px 0 8px;text-transform:uppercase;letter-spacing:0.05em"; + subtitle.textContent = `${sideLabel} · ${node.family}`; + el.appendChild(subtitle); + + const addRow = (label: string, value: string) => { + const row = document.createElement("div"); + row.style.cssText = + "display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6"; + const lbl = document.createElement("span"); + lbl.style.color = "#64748b"; + lbl.textContent = label; + const val = document.createElement("span"); + val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums"; + val.textContent = value; + row.appendChild(lbl); + row.appendChild(val); + el.appendChild(row); + }; + + addRow(t("modelDelegation.tooltip.agentRuns"), node.agentCount.toLocaleString()); + addRow(t("modelDelegation.tooltip.shareOfAll"), sharePct); + if (node.side === "main") { + addRow(t("modelDelegation.tooltip.sessionsOnModel"), String(node.sessionCount)); + } + addRow(t("modelDelegation.tooltip.totalTokens"), node.totalTokens.toLocaleString()); + + const desc = document.createElement("p"); + desc.style.cssText = + "font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:8px 0 0"; + desc.textContent = describeFamily(node.family, t); + el.appendChild(desc); + + const hint = document.createElement("p"); + hint.style.cssText = "font-size:11px;color:#64748b;line-height:1.45;margin:6px 0 0"; + hint.textContent = + node.side === "main" + ? t("modelDelegation.tooltip.lines.main") + : t("modelDelegation.tooltip.lines.sub"); + el.appendChild(hint); +} diff --git a/client/src/components/workflows/SessionComplexityScatter.tsx b/client/src/components/workflows/SessionComplexityScatter.tsx new file mode 100644 index 00000000..f2dc3245 --- /dev/null +++ b/client/src/components/workflows/SessionComplexityScatter.tsx @@ -0,0 +1,394 @@ +/** + * @file SessionComplexityScatter.tsx + * @description A React component that renders a scatter plot visualization of session complexity using D3.js. Each session is represented as a bubble, where the x-axis represents the session duration, the y-axis represents the number of agents involved, and the size of the bubble corresponds to the total tokens used. The color of each bubble indicates the session status (e.g., completed, active, error, abandoned). The component also includes tooltips for detailed information on hover and a legend for status colors. It is designed to be responsive and provides an empty state when no data is available. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Internal dependencies + * - `../../lib/types` + * - `../../lib/format` + * + * ## Public surface + * - `SessionComplexityScatterProps` — exported API; see TSDoc on the symbol for behavior. + * - `SessionComplexityScatter` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **SessionComplexityScatterProps** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **SessionComplexityScatter** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useRef, useEffect, useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import * as d3 from "d3"; +import type { SessionComplexityItem } from "../../lib/types"; +import { formatModelName } from "../../lib/format"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const MARGIN = { top: 20, right: 24, bottom: 60, left: 52 }; +const MIN_BUBBLE_R = 4; +const MAX_BUBBLE_R = 32; + +const STATUS_COLOR: Record = { + completed: "#22c55e", + error: "#ef4444", + active: "#6366f1", + abandoned: "#eab308", +}; + +function statusColor(status: string): string { + return STATUS_COLOR[status] ?? "#6b7280"; +} + +// ── Duration formatting ─────────────────────────────────────────────────────── + +function formatDurationSec(sec: number): string { + if (sec < 60) return `${Math.round(sec)}s`; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + if (h > 0) return `${h}h ${m}m`; + const s = Math.round(sec % 60); + return s > 0 ? `${m}m ${s}s` : `${m}m`; +} + +function fmtXTick(sec: number): string { + if (sec === 0) return "0"; + if (sec < 60) return `${Math.round(sec)}s`; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + if (h > 0) return `${h}h ${m > 0 ? `${m}m` : ""}`; + return `${m}m`; +} + +function fmtTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return String(n); +} + +// ── Tooltip ─────────────────────────────────────────────────────────────────── + +interface TooltipState { + x: number; + y: number; + item: SessionComplexityItem; +} + +function Tooltip({ state }: { state: TooltipState }) { + const { t } = useTranslation("workflows"); + const nearRight = state.x > window.innerWidth - 220; + + return ( +
+

+ {state.item.name ?? state.item.id.slice(0, 12)} +

+
+ + {t("complexity.tooltip.duration")} {formatDurationSec(state.item.duration)} + + + {t("complexity.tooltip.agents")} {state.item.agentCount} + + + {t("complexity.tooltip.subagents")} {state.item.subagentCount} + + + {t("complexity.tooltip.tokens")} {fmtTokens(state.item.totalTokens)} + + {state.item.model && ( + + {t("complexity.tooltip.model")} {formatModelName(state.item.model)} + + )} +
+
+ + {t(`common:status.${state.item.status}`, { defaultValue: state.item.status })} + +
+
+ ); +} + +// ── Legend ──────────────────────────────────────────────────────────────────── + +const LEGEND_STATUSES = ["completed", "active", "error", "abandoned"] as const; + +function Legend() { + const { t } = useTranslation("workflows"); + return ( +
+ {LEGEND_STATUSES.map((s) => ( +
+ + + {t(`common:status.${s}`, { defaultValue: s })} + +
+ ))} +
+ ); +} + +// ── Empty state ─────────────────────────────────────────────────────────────── + +function EmptyState() { + const { t } = useTranslation("workflows"); + return ( +
+
+ + + + + +
+

{t("complexity.noData")}

+

{t("complexity.noDataDesc")}

+
+ ); +} + +// ── Main chart ──────────────────────────────────────────────────────────────── + +export interface SessionComplexityScatterProps { + data: SessionComplexityItem[]; + onSessionClick?: (id: string) => void; +} + +export function SessionComplexityScatter({ data, onSessionClick }: SessionComplexityScatterProps) { + const { t } = useTranslation("workflows"); + const svgRef = useRef(null); + const containerRef = useRef(null); + const [tooltip, setTooltip] = useState(null); + const [width, setWidth] = useState(600); + + // Track container width for responsiveness + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const obs = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w) setWidth(w); + }); + obs.observe(el); + setWidth(el.clientWidth); + return () => obs.disconnect(); + }, []); + + const height = Math.max(280, Math.min(420, width * 0.5)); + + const handleMouseLeave = useCallback(() => setTooltip(null), []); + + useEffect(() => { + if (!svgRef.current || data.length === 0) return; + + const svg = d3.select(svgRef.current); + svg.selectAll("*").remove(); + + const innerW = width - MARGIN.left - MARGIN.right; + const innerH = height - MARGIN.top - MARGIN.bottom; + + const g = svg.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`); + + // Scales + const xScale = d3 + .scaleLinear() + .domain([0, d3.max(data, (d) => d.duration) ?? 1]) + .nice() + .range([0, innerW]); + + const yScale = d3 + .scaleLinear() + .domain([0, (d3.max(data, (d) => d.agentCount) ?? 1) + 1]) + .nice() + .range([innerH, 0]); + + const rScale = d3 + .scaleSqrt() + .domain([0, d3.max(data, (d) => d.totalTokens) ?? 1]) + .range([MIN_BUBBLE_R, MAX_BUBBLE_R]); + + // Grid lines + const gridColor = "#2a2a3d"; + + g.append("g") + .attr("class", "grid-x") + .call( + d3 + .axisBottom(xScale) + .ticks(5) + .tickSize(-innerH) + .tickFormat(() => "") + ) + .attr("transform", `translate(0,${innerH})`) + .call((sel) => { + sel.select(".domain").remove(); + sel.selectAll(".tick line").attr("stroke", gridColor).attr("stroke-dasharray", "3,3"); + }); + + g.append("g") + .attr("class", "grid-y") + .call( + d3 + .axisLeft(yScale) + .ticks(5) + .tickSize(-innerW) + .tickFormat(() => "") + ) + .call((sel) => { + sel.select(".domain").remove(); + sel.selectAll(".tick line").attr("stroke", gridColor).attr("stroke-dasharray", "3,3"); + }); + + // X axis + g.append("g") + .attr("transform", `translate(0,${innerH})`) + .call( + d3 + .axisBottom(xScale) + .ticks(5) + .tickFormat((d) => fmtXTick(d as number)) + ) + .call((sel) => { + sel.select(".domain").attr("stroke", "#363650"); + sel.selectAll(".tick line").attr("stroke", "#363650"); + sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11"); + }); + + // X axis label + g.append("text") + .attr("x", innerW / 2) + .attr("y", innerH + 44) + .attr("text-anchor", "middle") + .attr("fill", "#6b7280") + .attr("font-size", "11") + .text(t("complexity.duration")); + + // Y axis + g.append("g") + .call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d"))) + .call((sel) => { + sel.select(".domain").attr("stroke", "#363650"); + sel.selectAll(".tick line").attr("stroke", "#363650"); + sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11"); + }); + + // Y axis label + g.append("text") + .attr("transform", "rotate(-90)") + .attr("x", -innerH / 2) + .attr("y", -40) + .attr("text-anchor", "middle") + .attr("fill", "#6b7280") + .attr("font-size", "11") + .text(t("complexity.agentCount")); + + // Bubbles - sort largest to back so small ones are clickable + const sorted = [...data].sort((a, b) => b.totalTokens - a.totalTokens); + + g.selectAll("circle") + .data(sorted) + .join("circle") + .attr("cx", (d) => xScale(d.duration)) + .attr("cy", (d) => yScale(d.agentCount)) + .attr("r", (d) => rScale(d.totalTokens)) + .attr("fill", (d) => statusColor(d.status)) + .attr("fill-opacity", 0.75) + .attr("stroke", (d) => statusColor(d.status)) + .attr("stroke-width", 1.5) + .attr("stroke-opacity", 0.9) + .style("cursor", onSessionClick ? "pointer" : "default") + .on("mouseenter", (event: MouseEvent, d) => { + d3.select(event.currentTarget as SVGCircleElement) + .attr("fill-opacity", 1) + .attr("stroke-width", 2.5); + setTooltip({ x: event.clientX, y: event.clientY, item: d }); + }) + .on("mousemove", (event: MouseEvent) => { + setTooltip((prev) => (prev ? { ...prev, x: event.clientX, y: event.clientY } : null)); + }) + .on("mouseleave", (event: MouseEvent) => { + d3.select(event.currentTarget as SVGCircleElement) + .attr("fill-opacity", 0.75) + .attr("stroke-width", 1.5); + setTooltip(null); + }) + .on("click", (_event: MouseEvent, d) => { + onSessionClick?.(d.id); + }); + }, [data, width, height, onSessionClick, t]); + + if (data.length === 0) return ; + + return ( +
+ + + {tooltip && } +
+ ); +} diff --git a/client/src/components/workflows/SkillUsagePanel.tsx b/client/src/components/workflows/SkillUsagePanel.tsx new file mode 100644 index 00000000..d83c9d44 --- /dev/null +++ b/client/src/components/workflows/SkillUsagePanel.tsx @@ -0,0 +1,113 @@ +/** + * @file SkillUsagePanel.tsx + * @description Which skills and plugins actually get used, from + * `GET /api/workflows/skill-usage`: plugin totals as chips (skill calls + the + * plugin's own MCP tool calls) over a ranked list of individual skills with + * their call count and how many sessions reached for them. A skill that is + * installed but never invoked simply never appears — the panel reports use, not + * inventory. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api } from "../../lib/api"; +import { eventBus } from "../../lib/eventBus"; +import type { SkillUsage } from "../../lib/types"; + +export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) { + const { t } = useTranslation("workflows"); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setData(await api.workflows.skillUsage(statusFilter)); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : t("failedLoad")); + } + }, [statusFilter, t]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + let timer: ReturnType; + const unsub = eventBus.subscribe(() => { + clearTimeout(timer); + timer = setTimeout(() => void refresh(), 3000); + }); + return () => { + unsub(); + clearTimeout(timer); + }; + }, [refresh]); + + if (error) return

{error}

; + if (!data) return null; + if (!data.skills.length && !data.plugins.length) { + return

{t("skillUsage.empty")}

; + } + + const topCount = Math.max(...data.skills.map((s) => s.count), 1); + + return ( +
+ {data.plugins.length > 0 && ( +
+

+ {t("skillUsage.plugins")} +

+
+ {data.plugins.map((p) => ( + + {p.name} + {p.skillCalls + p.mcpCalls} + + ))} +
+
+ )} + +
+

+ {t("skillUsage.skills")} +

+
+ {data.skills.map((s) => ( +
+ + {s.name} + +
+
+
+ + {t("skillUsage.calls", { count: s.count })} ·{" "} + {t("skillUsage.sessions", { count: s.sessions })} + +
+ ))} +
+
+
+ ); +} diff --git a/client/src/components/workflows/WorkflowPatterns.tsx b/client/src/components/workflows/WorkflowPatterns.tsx new file mode 100644 index 00000000..a7fb059f --- /dev/null +++ b/client/src/components/workflows/WorkflowPatterns.tsx @@ -0,0 +1,395 @@ +/** + * @file WorkflowPatterns.tsx + * @description Defines the WorkflowPatterns React component that visualizes common workflow patterns detected from session data. It displays a ranked list of patterns based on their frequency, showing the sequence of agent steps in each pattern along with an icon representing the type of workflow. The component also handles cases where no patterns are detected and includes a special item for solo sessions without subagents. Users can click on a pattern to trigger a callback with the pattern's steps for further analysis or filtering. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Internal dependencies + * - `../../lib/types` + * + * ## Public surface + * - `WorkflowPatterns` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **WorkflowPatterns** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronRight, Zap, Code2, Shield, Bug, FileText, Lightbulb, Info } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { WorkflowPattern, WorkflowPatternsData } from "../../lib/types"; + +type TFn = (key: string, options?: Record) => string; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const MAX_VISIBLE_STEPS = 4; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function patternIcon(steps: string[]): LucideIcon { + const joined = steps.join(" ").toLowerCase(); + if (joined.includes("debug")) return Bug; + if (joined.includes("security") || joined.includes("audit")) return Shield; + if (joined.includes("code-review") || joined.includes("review")) return Code2; + if (joined.includes("doc") || joined.includes("text")) return FileText; + return Zap; +} + +/** + * Find the first agent that appears more than once in the sequence (loop indicator). + * Returns null if every step is unique. + */ +function findRepeatedStep(steps: string[]): string | null { + for (let i = 0; i < steps.length; i++) { + const s = steps[i]; + if (s !== undefined && steps.indexOf(s) !== i) return s; + } + return null; +} + +/** + * Build a deterministic, value-dependent narrative for a workflow pattern. + * Pure rule-based mapping - same input always yields the same output, so the + * UI never produces hallucinated descriptions for ambiguous patterns. + */ +function describePattern(pattern: WorkflowPattern, t: TFn): string { + const { steps, percentage } = pattern; + if (steps.length === 0) return ""; + + const first = steps[0] ?? ""; + const last = steps[steps.length - 1] ?? first; + const repeated = findRepeatedStep(steps); + + // Shape of the chain + let core: string; + if (steps.length === 1) { + core = t("patterns.detail.narrative.soloFmt", { first }); + } else if (steps.length === 2) { + core = t("patterns.detail.narrative.twoStepFmt", { first, last }); + } else if (steps.length <= 5) { + core = t("patterns.detail.narrative.shortFmt", { count: steps.length, first, last }); + } else { + core = t("patterns.detail.narrative.longFmt", { count: steps.length, first, last }); + } + + if (repeated) { + core += t("patterns.detail.narrative.loopHintFmt", { agent: repeated }); + } + + // Frequency bucket + let freq: string; + if (percentage > 50) freq = t("patterns.detail.narrative.dominant"); + else if (percentage > 25) freq = t("patterns.detail.narrative.common"); + else if (percentage > 10) freq = t("patterns.detail.narrative.regular"); + else freq = t("patterns.detail.narrative.niche"); + + return core + freq; +} + +/** + * Pick a suggestion bucket based on chain length and whether a loop exists. + * Loop wins over length so the user is reminded to confirm intentional loops. + */ +function suggestionForPattern(pattern: WorkflowPattern, t: TFn): string { + const { steps } = pattern; + if (findRepeatedStep(steps)) return t("patterns.detail.suggestion.loop"); + if (steps.length <= 1) return t("patterns.detail.suggestion.solo"); + if (steps.length <= 3) return t("patterns.detail.suggestion.shortChain"); + if (steps.length <= 6) return t("patterns.detail.suggestion.mediumChain"); + return t("patterns.detail.suggestion.longChain"); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function StepPill({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function StepFlow({ steps }: { steps: string[] }) { + const { t } = useTranslation("workflows"); + const visible = steps.slice(0, MAX_VISIBLE_STEPS); + const overflow = steps.length - MAX_VISIBLE_STEPS; + + return ( +
+ {visible.map((step, idx) => ( + + + {(idx < visible.length - 1 || overflow > 0) && ( + + )} + + ))} + {overflow > 0 && ( + + {t("common:plusMore", { count: overflow })} + + )} +
+ ); +} + +function PatternFrequency({ count, percentage }: { count: number; percentage: number }) { + const { t } = useTranslation("workflows"); + return ( +
+

{count.toLocaleString()}

+

+ {percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })} +

+
+ ); +} + +interface PatternItemProps { + pattern: WorkflowPattern; + rank: number; + isSelected: boolean; + onClick: () => void; +} + +function PatternItem({ pattern, rank, isSelected, onClick }: PatternItemProps) { + const { t } = useTranslation("workflows"); + const Icon = patternIcon(pattern.steps); + + return ( +
+ + + {isSelected && } +
+ ); +} + +function PatternDetail({ pattern }: { pattern: WorkflowPattern }) { + const { t } = useTranslation("workflows"); + const uniqueAgents = new Set(pattern.steps).size; + const narrative = describePattern(pattern, t); + const suggestion = suggestionForPattern(pattern, t); + + return ( +
+ {/* Full step sequence (no truncation) */} +
+

+ {t("patterns.detail.stepsHeading")} +

+
+ {pattern.steps.map((step, i) => ( + + + {i + 1} + {step} + + {i < pattern.steps.length - 1 && ( + + )} + + ))} +
+
+ + {/* Stats grid */} +
+ + + + +
+ + {/* Narrative - what this means */} +
+

+ + {t("patterns.detail.narrativeHeading")} +

+

{narrative}

+
+ + {/* Suggestion */} +
+

+ + {t("patterns.detail.suggestionHeading")} +

+

{suggestion}

+
+
+ ); +} + +function DetailStat({ label, value }: { label: string; value: string }) { + return ( +
+

{value}

+

{label}

+
+ ); +} + +function SoloSessionItem({ count, percentage }: { count: number; percentage: number }) { + const { t } = useTranslation("workflows"); + return ( +
+
+ +
+
+ + {t("patterns.solo")} + +
+
+

{count.toLocaleString()}

+

+ {percentage.toFixed(1)}% {t("common:ofSessions", { defaultValue: "of sessions" })} +

+
+
+ ); +} + +function EmptyPatterns() { + const { t } = useTranslation("workflows"); + return ( +
+
+ +
+

{t("patterns.noData")}

+

{t("patterns.noDataDesc")}

+
+ ); +} + +// ── Public component ────────────────────────────────────────────────────────── + +interface WorkflowPatternsProps { + data: WorkflowPatternsData; + onPatternClick?: (steps: string[]) => void; +} + +export function WorkflowPatterns({ data, onPatternClick }: WorkflowPatternsProps) { + const { t } = useTranslation("workflows"); + const [selectedIndex, setSelectedIndex] = useState(null); + + const handlePatternClick = (index: number, steps: string[]) => { + const next = selectedIndex === index ? null : index; + setSelectedIndex(next); + if (next !== null) { + onPatternClick?.(steps); + } + }; + + const hasContent = data.patterns.length > 0 || data.soloSessionCount > 0; + + return ( +
+

+ {t("patterns.label")} +

+ + {!hasContent ? ( + + ) : ( +
+ {data.patterns.map((pattern, idx) => ( + handlePatternClick(idx, pattern.steps)} + /> + ))} + + {data.soloSessionCount > 0 && ( + + )} +
+ )} +
+ ); +} diff --git a/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx b/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx index a2684ab4..d749466b 100644 --- a/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx +++ b/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx @@ -1,115 +1,106 @@ /** - * @file Tests for the Workflows page's lane pipeline panel: it lists every lane - * as a tile, shows the first lane's pipeline before anything is clicked, and - * swaps the map when another lane is selected. A wrong selection would show one - * lane's stage under another lane's name. + * @file Tests for the Workflows page's pipeline pass-rate panel: it renders one + * row per node of the busiest pipeline, distinguishes a proved pass from a + * merely claimed one, and switches templates through the picker. Conflating + * evidence with a bare claim is the failure that matters here — it would make + * an undocumented stage look verified. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; +import { render, screen, fireEvent, within } from "@testing-library/react"; import { LanePipelinePanel } from "../LanePipelinePanel"; -const { LANES } = vi.hoisted(() => { - const laneStub = (id: number, stageId: string) => ({ - id, - title: `lane-${id}`, - cwd: `/workspace/lane-${id}`, - branch: null, - kind: "adopted", - source_repo: null, - pipeline: "default", - session_id: null, - run_id: null, - stage: stageId, - stage_since: null, - status: "idle", - gate_decision: null, - ci_status: null, - needs_action: null, - links: {}, - stages: {}, - notes: null, - pipeline_name: "Default", - pipeline_nodes: [ - { - id: stageId, - label: stageId, - icon: "🧭", - gate: false, - state: "current", - }, - ], - progress: 25, - stage_seconds: null, - last_event_seconds: null, - liveness: "idle", - detected_stage: null, - detected_signal: null, - slot: null, - ports: {}, - active_feature_id: null, - }); - return { LANES: [laneStub(1, "plan"), laneStub(2, "tests")] }; -}); - -const listMock = vi.fn(); +const statsMock = vi.fn(); vi.mock("../../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); - return { ...actual, api: { lanes: { list: () => listMock() } } }; + return { ...actual, api: { lanes: { pipelineStats: () => statsMock() } } }; }); -function renderPanel() { - return render( - - - - ); -} +const node = (id: string, over: Record = {}) => ({ + id, + label: id, + icon: "🧭", + gate: false, + entered: 0, + passed: 0, + withEvidence: 0, + failed: 0, + ...over, +}); + +const STATS = { + pipelines: [ + { + id: "default", + name: "Default feature pipeline", + runs: 4, + nodes: [ + node("plan", { entered: 4, passed: 4, withEvidence: 4 }), + node("implement", { entered: 4, passed: 4, withEvidence: 0 }), + node("tests", { entered: 3, passed: 2, withEvidence: 2, failed: 1 }), + node("ship"), + ], + }, + { id: "hotfix", name: "Hotfix", runs: 1, nodes: [node("patch", { entered: 1, passed: 1 })] }, + ], +}; describe("LanePipelinePanel", () => { - it("shows the first lane's pipeline before any tile is clicked", async () => { - listMock.mockResolvedValue({ - lanes: LANES, - counts: { total: 2, running: 0, needs_you: 0, dead: 0 }, - }); - renderPanel(); + it("shows pass count, entries and rate per node of the busiest pipeline", async () => { + statsMock.mockResolvedValue(STATS); + render(); - expect(await screen.findByTestId("lane-tile-1")).toHaveAttribute("data-selected", "true"); - expect(screen.getByTestId("pipeline-node-plan")).toBeInTheDocument(); - expect(screen.queryByTestId("pipeline-node-tests")).not.toBeInTheDocument(); + const tests = await screen.findByTestId("pipeline-stat-tests"); + expect(within(tests).getByText("2")).toBeInTheDocument(); + expect(tests).toHaveTextContent("2/3"); + expect(tests).toHaveTextContent("67%"); }); - it("swaps the pipeline map when another lane is selected", async () => { - listMock.mockResolvedValue({ - lanes: LANES, - counts: { total: 2, running: 0, needs_you: 0, dead: 0 }, - }); - renderPanel(); + it("draws a claimed pass in a different colour from a proved one", async () => { + statsMock.mockResolvedValue(STATS); + render(); - fireEvent.click(await screen.findByTestId("lane-tile-2")); - - expect(screen.getByTestId("pipeline-node-tests")).toBeInTheDocument(); - expect(screen.queryByTestId("pipeline-node-plan")).not.toBeInTheDocument(); + const proved = await screen.findByTestId("pipeline-stat-plan"); + const claimed = screen.getByTestId("pipeline-stat-implement"); + // Same counts, different bar: plan is all evidence, implement is all claim. + expect(proved.querySelector(".bg-status-success")).toHaveStyle({ width: "100%" }); + expect(claimed.querySelector(".bg-status-success")).toHaveStyle({ width: "0%" }); + expect(claimed.querySelector(".bg-status-warning")).toHaveStyle({ width: "100%" }); }); - it("renders the CLI hint when no lane exists", async () => { - listMock.mockResolvedValue({ - lanes: [], - counts: { total: 0, running: 0, needs_you: 0, dead: 0 }, + it("marks a node that was never reached instead of showing 0%", async () => { + statsMock.mockResolvedValue(STATS); + render(); + + const ship = await screen.findByTestId("pipeline-stat-ship"); + expect(ship).not.toHaveTextContent("%"); + }); + + it("switches templates through the picker", async () => { + statsMock.mockResolvedValue(STATS); + render(); + + fireEvent.change(await screen.findByTestId("pipeline-stats-picker"), { + target: { value: "hotfix" }, }); - renderPanel(); + + expect(screen.getByTestId("pipeline-stat-patch")).toBeInTheDocument(); + expect(screen.queryByTestId("pipeline-stat-plan")).not.toBeInTheDocument(); + }); + + it("shows the CLI hint when nothing has been recorded", async () => { + statsMock.mockResolvedValue({ pipelines: [] }); + render(); expect(await screen.findByText(/ccam lanes add/)).toBeInTheDocument(); - expect(screen.queryByTestId("workflows-lane-strip")).not.toBeInTheDocument(); }); - it("surfaces a failed fetch instead of an empty strip", async () => { - listMock.mockRejectedValue(new Error("lanes unreachable")); - renderPanel(); + it("surfaces a failed fetch", async () => { + statsMock.mockRejectedValue(new Error("stats unreachable")); + render(); - expect(await screen.findByText("lanes unreachable")).toBeInTheDocument(); + expect(await screen.findByText("stats unreachable")).toBeInTheDocument(); }); }); diff --git a/client/src/components/workflows/__tests__/SkillUsagePanel.test.tsx b/client/src/components/workflows/__tests__/SkillUsagePanel.test.tsx new file mode 100644 index 00000000..bf29e451 --- /dev/null +++ b/client/src/components/workflows/__tests__/SkillUsagePanel.test.tsx @@ -0,0 +1,58 @@ +/** + * @file Tests for the skill/plugin usage panel: plugin chips carry the combined + * skill + MCP call count, skills rank by calls, and the status filter is passed + * through to the API so the panel matches the rest of the page. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SkillUsagePanel } from "../SkillUsagePanel"; + +const usageMock = vi.fn(); + +vi.mock("../../../lib/api", async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, api: { workflows: { skillUsage: (s?: string) => usageMock(s) } } }; +}); + +const USAGE = { + skills: [ + { name: "ccam:ship-feature", plugin: "ccam", count: 12, sessions: 5 }, + { name: "daily-report", plugin: null, count: 3, sessions: 3 }, + ], + plugins: [{ name: "ccam", skills: 1, skillCalls: 12, mcpCalls: 4 }], +}; + +describe("SkillUsagePanel", () => { + it("sums a plugin's skill calls and MCP calls into one chip total", async () => { + usageMock.mockResolvedValue(USAGE); + render(); + + expect(await screen.findByTestId("plugin-usage-ccam")).toHaveTextContent("16"); + }); + + it("lists each skill with its call and session counts", async () => { + usageMock.mockResolvedValue(USAGE); + render(); + + const row = await screen.findByTestId("skill-usage-ccam:ship-feature"); + expect(row).toHaveTextContent("12"); + expect(row).toHaveTextContent("5"); + }); + + it("forwards the page's status filter to the API", async () => { + usageMock.mockResolvedValue(USAGE); + render(); + + await screen.findByTestId("plugin-usage-ccam"); + expect(usageMock).toHaveBeenCalledWith("active"); + }); + + it("shows an empty state when nothing was invoked", async () => { + usageMock.mockResolvedValue({ skills: [], plugins: [] }); + render(); + + expect(await screen.findByText(/No skill invocation/i)).toBeInTheDocument(); + }); +}); diff --git a/client/src/i18n/locales/en/workflows.json b/client/src/i18n/locales/en/workflows.json index b809a46c..5dc8eef5 100644 --- a/client/src/i18n/locales/en/workflows.json +++ b/client/src/i18n/locales/en/workflows.json @@ -69,9 +69,39 @@ "why": "When an aggregate chart flags a suspicious session, this is where you go to read exactly what happened, in order." }, "lanes": { - "what": "The lanes this dashboard tracks — one durable unit of parallel agent work per working directory — and the pipeline stages each one has reached.", - "howToRead": "Pick a lane tile to see its pipeline below. A node is green when it is done with evidence, solid blue for the current stage, amber when claimed without evidence, dashed amber when only detected from tool events, red when rejected.", - "why": "This is your own delivery pipeline, not Claude Code internals: it answers where each parallel workstream stands right now. Lane actions live on the Workspace page." + "what": "Every stage record across all your lanes and their archived features, counted per pipeline node: how many times a stage was entered, passed, and how many of those passes left evidence behind.", + "howToRead": "One row per node. The bar is scaled against the busiest node, split into green (passed with evidence), amber (passed but only claimed) and red (failed). The number on the right is passes / entries and the pass rate.", + "why": "A node with a long amber bar is a stage everyone declares and nobody proves; a node that is never reached is a stage your pipeline does not really use." + }, + "patterns": { + "what": "A ranked list of recurring agent-orchestration sequences detected from session traces, plus a callout for solo sessions that ran without subagents.", + "howToRead": "Each row is a step sequence (main → sub → sub …). The right side shows how many sessions matched it and what percentage of all sessions that represents. Click a pattern to highlight it.", + "why": "Reveals the standard 'shapes' of work in your project. Frequent patterns are good candidates to formalize as named workflows or skills." + }, + "modelDelegation": { + "what": "A two-column flow showing which main models delegate to which subagent models, plus per-node agent counts and total tokens.", + "howToRead": "Left column = main models for sessions; right column = models used inside subagents. Lines indicate observed main → subagent pairings. Hover a node for share-of-all-agents, sessions, total tokens, and a description of the model family (Opus / Sonnet / Haiku).", + "why": "Helps you confirm cost/latency tiering - e.g. main on Sonnet, subagents on Haiku - and catch unintended escalations to Opus." + }, + "errorPropagation": { + "what": "Where errors cluster across the agent hierarchy: by depth (session/main vs direct subagent vs nested), by agent type, and by API/session-level error category.", + "howToRead": "The top banner gives the session-level error rate. The depth bars show how many errors occurred at each nesting level. The error-prone types list ranks subagent types by error count. The bottom block surfaces API/session error summaries.", + "why": "Pinpoints whether failures live in the orchestration spine (main agent), in specific subagents, or in deep nested chains - guiding where to invest in retries, validation, or rewrites." + }, + "complexity": { + "what": "A scatter plot of every session: x = wall-clock duration, y = total agents involved, bubble size = total tokens, color = final status.", + "howToRead": "Top-right bubbles are long, agent-heavy sessions. Big bubbles burned the most tokens. Reds/yellows = error/abandoned outcomes. Hover any bubble for full session stats; click to drill into it below.", + "why": "Gives a single view of the cost/complexity distribution. Outliers are the sessions worth investigating for runaway behavior or cost spikes." + }, + "compaction": { + "what": "How often Claude triggered context compaction across sessions: total events, tokens recovered, and a per-session distribution.", + "howToRead": "The two stat boxes show total compaction events and the cumulative tokens reclaimed. The bar chart lists sessions with at least one compaction, sorted descending by count.", + "why": "Compactions indicate context pressure. A long tail of high-compaction sessions usually means workflows that should be split, summarized, or migrated to a longer-context model." + }, + "skillUsage": { + "what": "Skill invocations counted from Skill tool events, grouped by skill and by owning plugin. A plugin's total also counts calls to the MCP tools it ships.", + "howToRead": "Chips on top are plugins with their combined call count; the list below ranks individual skills by number of calls, with how many distinct sessions used each one.", + "why": "Shows which parts of your setup earn their keep. A skill installed but never invoked never appears here — this reports use, not inventory." } }, "stats": { @@ -236,8 +266,151 @@ "sortedByCompaction": " session(s) (sorted by compaction count)" }, "lanes": { - "title": "Lane Pipelines", - "subtitle": "Your own lanes and the stage each one has reached", - "openWorkspace": "Open in Workspace" + "title": "Lane Pipeline Pass Rates", + "subtitle": "How often each stage of your own pipelines is actually passed", + "pipelineLabel": "Pipeline template", + "runs_one": "{{count}} run recorded", + "runs_other": "{{count}} runs recorded", + "legendEvidence": "passed with evidence", + "legendClaimed": "passed, no evidence", + "legendFailed": "failed", + "never": "never reached", + "empty": "No lane has recorded a stage yet. Create one with" + }, + "patterns": { + "title": "Detected Workflow Patterns", + "subtitle": "Common agent orchestration sequences", + "noData": "No patterns detected yet", + "noDataDesc": "Patterns appear once multiple sessions share the same agent sequence.", + "solo": "solo (no subagents)", + "label": "Workflow Patterns", + "detail": { + "clickHint": "Click for details", + "stepsHeading": "Full step sequence", + "statsHeading": "Stats", + "stepsCount_one": "{{count}} step", + "stepsCount_other": "{{count}} steps", + "uniqueAgents": "Unique agent types", + "occurrences": "Sessions matching this pattern", + "shareOfSessions": "Share of all sessions", + "narrativeHeading": "What this means", + "suggestionHeading": "Suggestion", + "narrative": { + "soloFmt": "Sessions that ran with only {{first}} - no delegation to other agent types.", + "twoStepFmt": "A two-step hand-off: {{first}} delegates straight to {{last}}, with no further branching.", + "shortFmt": "A {{count}}-step chain that starts at {{first}} and ends at {{last}}.", + "longFmt": "A long {{count}}-step chain - begins at {{first}}, threads through several agents, and lands at {{last}}.", + "loopHintFmt": " {{agent}} appears more than once in this sequence - typical of a verification or revision loop.", + "dominant": " This is the dominant workflow in your project - more than half of sessions follow it.", + "common": " This is a common workflow, accounting for roughly a quarter of sessions.", + "regular": " A recognised workflow, but not the majority pattern.", + "niche": " A niche pattern that only surfaces occasionally." + }, + "suggestion": { + "solo": "Solo run - would a code-reviewer or test-runner subagent raise the quality of these sessions?", + "shortChain": "Short chain. If it keeps recurring, name it as a slash command, skill, or named workflow so it's easy to reproduce.", + "mediumChain": "Frequent enough to formalise. A named workflow or template would make this pattern reusable.", + "longChain": "Long chain - review whether every hop is necessary, or whether some agents could be merged or skipped.", + "loop": "Loop detected. Confirm the repeated step is intentional verification rather than a silent retry of a failing call." + } + } + }, + "modelDelegation": { + "title": "Model Delegation Flow", + "subtitle": "How models route through agent hierarchies", + "mainModels": "MAIN MODELS", + "subagentModels": "SUBAGENT MODELS", + "noData": "No model delegation data available", + "agentsCount_one": "{{count}} agent", + "agentsCount_other": "{{count}} agents", + "tokensCount": "{{tokens}} tokens", + "sessionsCount_one": "{{count}} session", + "sessionsCount_other": "{{count}} sessions", + "ariaLabel": "Model delegation flow diagram", + "tooltip": { + "mainModel": "Main model", + "subagentModel": "Subagent model", + "agentRuns": "Agent runs", + "shareOfAll": "Share of all agents", + "sessionsOnModel": "Sessions on this model", + "totalTokens": "Total tokens", + "family": { + "opus": "Opus - highest-capability tier; best for complex reasoning, longest context, costliest per token.", + "sonnet": "Sonnet - balanced tier; good default for most coding and tool-use workflows.", + "haiku": "Haiku - fastest, cheapest tier; ideal for short, latency-sensitive subagents.", + "other": "Non-Anthropic or unrecognized model - falls outside the standard Opus/Sonnet/Haiku families." + }, + "lines": { + "main": "Lines on the right show subagent models this main model delegated work to.", + "sub": "Lines on the left show main models that delegated work to this subagent model." + } + } + }, + "errorPropagation": { + "title": "Error Propagation Map", + "subtitle": "Where errors cluster in agent hierarchy depth", + "noErrors": "No errors recorded", + "allSuccess": "All sessions completed successfully", + "sessionsHadErrors": " sessions had errors", + "sessionsErrorSummary": "{{errorSessions}} of {{totalSessions}} sessions had errors", + "agentErrors": " agent-level error(s) across hierarchy", + "sessionErrorsOnly": "Session-level errors only", + "errorsByDepth": "Errors by hierarchy depth", + "errorProneTypes": "Error-prone agent types", + "apiSessionErrors": "API & session errors", + "depthLabels": { + "sessionMain": "Session / Main", + "directSubagent": "Direct Subagent", + "nested": "Nested (depth 2)", + "deep": "Deep (depth 3+)" + } + }, + "complexity": { + "title": "Session Complexity Scatter", + "subtitle": "Duration vs agent count vs tokens - Bubble size = token usage", + "noData": "No session complexity data", + "noDataDesc": "Sessions will appear here once they complete with agent activity.", + "duration": "Duration", + "agentCount": "Agent Count", + "ariaLabel": "Session complexity scatter plot", + "tooltip": { + "duration": "Duration:", + "agents": "Agents:", + "subagents": "Subagents:", + "tokens": "Tokens:", + "model": "Model:" + } + }, + "compaction": { + "title": "Compaction Impact Analysis", + "subtitle": "How context compression is spread across your sessions", + "help": "When a session's context window fills up, Claude Code compresses older history into a summary. Each compaction is one such event.", + "noData": "No compactions recorded", + "totalCompactions": "Total compactions", + "sessionsAffected": "Sessions affected", + "ofTotal": "of {{total}}", + "avgPerSession": "Avg / affected session", + "peakSession": "Most in one session", + "tokensRecovered": "Tokens Recovered", + "distribution": "Sessions by compaction count", + "xAxis": "Compactions per session", + "yAxis": "Sessions", + "summary": "{{affected}} of {{total}} sessions ({{pct}}%) used context compaction.", + "tokensFreed": "Compaction freed ~{{tokens}} tokens of context.", + "ariaLabel": "Number of sessions by compaction count", + "tipTitle": "Compacted {{count}}x", + "tipDetail": "{{sessions}} sessions · {{pct}}% of affected" + }, + "skillUsage": { + "title": "Skill & Plugin Usage", + "subtitle": "Which skills your sessions actually reach for", + "plugins": "Plugins", + "skills": "Skills", + "empty": "No skill invocation recorded yet.", + "calls_one": "{{count}} call", + "calls_other": "{{count}} calls", + "sessions_one": "{{count}} session", + "sessions_other": "{{count}} sessions", + "pluginBreakdown": "{{skills}} skill(s) used · {{skillCalls}} skill call(s) · {{mcpCalls}} MCP tool call(s)" } } diff --git a/client/src/i18n/locales/vi/workflows.json b/client/src/i18n/locales/vi/workflows.json index 876a5777..ff0052ea 100644 --- a/client/src/i18n/locales/vi/workflows.json +++ b/client/src/i18n/locales/vi/workflows.json @@ -69,9 +69,39 @@ "why": "Khi biểu đồ tổng hợp đánh dấu một phiên đáng ngờ, đây là nơi bạn đọc chính xác chuyện gì đã xảy ra, theo đúng thứ tự." }, "lanes": { - "what": "Các lane mà dashboard theo dõi — mỗi thư mục làm việc là một đơn vị công việc song song bền vững — cùng giai đoạn pipeline mỗi lane đã đạt tới.", - "howToRead": "Chọn một thẻ lane để xem pipeline bên dưới. Node xanh lá là đã xong kèm bằng chứng, xanh dương đặc là giai đoạn hiện tại, vàng là khai báo nhưng không có bằng chứng, vàng nét đứt là chỉ suy ra từ tool event, đỏ là bị từ chối.", - "why": "Đây là pipeline giao việc của chính bạn, không phải nội bộ Claude Code: nó trả lời từng luồng việc song song đang ở đâu. Các thao tác trên lane nằm ở trang Workspace." + "what": "Toàn bộ bản ghi giai đoạn của mọi lane và feature đã lưu trữ, đếm theo từng node pipeline: số lần vào giai đoạn, số lần pass, và bao nhiêu lần pass có kèm bằng chứng.", + "howToRead": "Mỗi node một dòng. Thanh bar so tỉ lệ với node bận nhất, chia ba màu: xanh lá (pass có bằng chứng), vàng (pass nhưng chỉ khai báo), đỏ (thất bại). Số bên phải là pass / số lần vào và tỉ lệ pass.", + "why": "Node có dải vàng dài là giai đoạn ai cũng khai báo nhưng không ai chứng minh; node chưa bao giờ tới là giai đoạn pipeline của bạn thực tế không dùng." + }, + "patterns": { + "what": "Danh sách xếp hạng các chuỗi điều phối Agent lặp lại được phát hiện từ trace phiên, kèm thẻ riêng cho phiên đơn lẻ không có Subagent.", + "howToRead": "Mỗi dòng là một chuỗi bước (main → sub → sub …). Bên phải hiển thị số phiên khớp và tỷ lệ trên tổng phiên. Bấm vào một mẫu để làm nổi bật.", + "why": "Cho thấy các 'hình dáng' công việc tiêu chuẩn trong dự án. Mẫu phổ biến là ứng viên tốt để chuẩn hóa thành workflow hoặc skill có tên gọi." + }, + "modelDelegation": { + "what": "Sơ đồ luồng hai cột cho thấy mô hình chính nào ủy quyền cho mô hình Subagent nào, kèm số Agent và tổng token theo node.", + "howToRead": "Cột trái = mô hình chính của phiên; cột phải = mô hình dùng bên trong Subagent. Đường nối chỉ các cặp main → Subagent quan sát được. Hover một node để xem tỷ trọng trên tổng Agent, số phiên, tổng token và mô tả họ mô hình (Opus / Sonnet / Haiku).", + "why": "Giúp xác nhận phân tầng chi phí/độ trễ - ví dụ main trên Sonnet, Subagent trên Haiku - và phát hiện các trường hợp bị đẩy lên Opus ngoài ý muốn." + }, + "errorPropagation": { + "what": "Lỗi tập trung ở đâu trong phân cấp Agent: theo độ sâu (phiên/main vs Subagent trực tiếp vs lồng nhau), theo loại Agent, và theo nhóm lỗi API / phiên.", + "howToRead": "Banner trên đầu cho biết tỷ lệ lỗi cấp phiên. Các thanh độ sâu cho biết bao nhiêu lỗi xảy ra ở từng cấp lồng. Danh sách 'loại dễ lỗi' xếp Subagent theo số lỗi. Khối dưới hiển thị tóm tắt lỗi API/phiên.", + "why": "Xác định lỗi nằm ở trục điều phối (main agent), ở Subagent cụ thể, hay ở chuỗi lồng sâu - giúp định hướng nơi nên đầu tư retry, kiểm chứng hay viết lại." + }, + "complexity": { + "what": "Biểu đồ phân tán mỗi phiên: x = thời lượng thực, y = tổng số Agent, kích thước bong bóng = tổng token, màu = trạng thái cuối.", + "howToRead": "Bong bóng phía trên-bên phải là phiên dài và nặng Agent. Bong bóng to dùng nhiều token nhất. Đỏ/vàng = kết quả lỗi/bỏ dở. Hover để xem đầy đủ thống kê phiên; bấm để xem chi tiết bên dưới.", + "why": "Cho cái nhìn tổng thể về phân bố chi phí/độ phức tạp. Outlier chính là những phiên đáng điều tra vì hành vi bất thường hoặc đột biến chi phí." + }, + "compaction": { + "what": "Tần suất Claude kích hoạt nén ngữ cảnh: tổng số sự kiện, token thu hồi và phân bố theo từng phiên.", + "howToRead": "Hai ô số liệu cho biết tổng số lần nén và tổng token đã thu hồi. Biểu đồ cột liệt kê các phiên có ít nhất một lần nén, sắp xếp giảm dần theo số lần.", + "why": "Nén ngữ cảnh là dấu hiệu áp lực bộ nhớ. Nhiều phiên có số lần nén cao thường có nghĩa workflow nên được chia nhỏ, tóm tắt, hoặc chuyển sang model có ngữ cảnh dài hơn." + }, + "skillUsage": { + "what": "Số lần gọi skill đếm từ event tool Skill, gom theo skill và theo plugin sở hữu. Tổng của một plugin còn tính cả lần gọi các tool MCP mà plugin đó cung cấp.", + "howToRead": "Các chip phía trên là plugin kèm tổng số lần gọi; danh sách bên dưới xếp hạng từng skill theo số lần gọi, kèm số phiên khác nhau đã dùng skill đó.", + "why": "Cho thấy phần nào trong bộ cài đặt thực sự đáng giữ. Skill cài mà không bao giờ gọi sẽ không xuất hiện ở đây — bảng này báo mức dùng, không phải danh mục." } }, "stats": { @@ -236,8 +266,151 @@ "sortedByCompaction": " phiên (sắp theo số lần nén ngữ cảnh)" }, "lanes": { - "title": "Pipeline của lane", - "subtitle": "Các lane của bạn và giai đoạn mỗi lane đã đạt tới", - "openWorkspace": "Mở trong Workspace" + "title": "Tỉ lệ pass các node pipeline", + "subtitle": "Mỗi giai đoạn trong pipeline của bạn thực sự pass bao nhiêu lần", + "pipelineLabel": "Mẫu pipeline", + "runs_one": "{{count}} lượt chạy được ghi nhận", + "runs_other": "{{count}} lượt chạy được ghi nhận", + "legendEvidence": "pass kèm bằng chứng", + "legendClaimed": "pass, không bằng chứng", + "legendFailed": "thất bại", + "never": "chưa từng tới", + "empty": "Chưa lane nào ghi nhận giai đoạn. Tạo lane bằng" + }, + "patterns": { + "title": "Mẫu quy trình phát hiện được", + "subtitle": "Chuỗi điều phối Agent thường gặp", + "noData": "Chưa phát hiện mẫu nào", + "noDataDesc": "Mẫu sẽ xuất hiện khi nhiều phiên có cùng chuỗi Agent.", + "solo": "đơn lẻ (không có Subagent)", + "label": "Mẫu quy trình", + "detail": { + "clickHint": "Bấm để xem chi tiết", + "stepsHeading": "Chuỗi bước đầy đủ", + "statsHeading": "Thống kê", + "stepsCount_one": "{{count}} bước", + "stepsCount_other": "{{count}} bước", + "uniqueAgents": "Số loại Agent khác nhau", + "occurrences": "Số phiên khớp mẫu này", + "shareOfSessions": "Tỷ trọng trên tổng phiên", + "narrativeHeading": "Ý nghĩa của mẫu này", + "suggestionHeading": "Gợi ý", + "narrative": { + "soloFmt": "Phiên chỉ chạy {{first}} - không ủy quyền cho loại Agent nào khác.", + "twoStepFmt": "Hand-off hai bước: {{first}} ủy quyền thẳng cho {{last}}, không có nhánh nào khác.", + "shortFmt": "Chuỗi {{count}} bước, bắt đầu ở {{first}} và kết thúc ở {{last}}.", + "longFmt": "Chuỗi dài {{count}} bước - bắt đầu ở {{first}}, đi qua nhiều Agent, rồi dừng ở {{last}}.", + "loopHintFmt": " {{agent}} xuất hiện nhiều lần trong chuỗi này - điển hình cho vòng lặp kiểm tra hoặc sửa lại.", + "dominant": " Đây là quy trình chủ đạo trong dự án - hơn một nửa số phiên đi theo.", + "common": " Là quy trình phổ biến, chiếm khoảng một phần tư số phiên.", + "regular": " Là quy trình được nhận diện, nhưng chưa phải đa số.", + "niche": " Là mẫu hiếm, chỉ thi thoảng mới xuất hiện." + }, + "suggestion": { + "solo": "Phiên đơn lẻ - liệu thêm code-reviewer hay test-runner Subagent có cải thiện chất lượng không?", + "shortChain": "Chuỗi ngắn. Nếu lặp lại nhiều, hãy đặt tên thành slash command, skill hoặc workflow để dễ tái sử dụng.", + "mediumChain": "Đủ phổ biến để chuẩn hóa. Một workflow/template có tên sẽ giúp tái sử dụng dễ dàng.", + "longChain": "Chuỗi dài - xem xét liệu mọi bước có thực sự cần thiết, hay có thể gộp/bỏ bớt một số Agent.", + "loop": "Phát hiện vòng lặp. Hãy xác nhận bước lặp là kiểm tra có chủ đích, không phải retry âm thầm một lệnh đang lỗi." + } + } + }, + "modelDelegation": { + "title": "Luồng ủy quyền mô hình", + "subtitle": "Cách các mô hình đi qua phân cấp Agent", + "mainModels": "MÔ HÌNH CHÍNH", + "subagentModels": "MÔ HÌNH TÁC TỬ PHỤ", + "noData": "Chưa có dữ liệu ủy quyền mô hình", + "agentsCount_one": "{{count}} Agent", + "agentsCount_other": "{{count}} Agent", + "tokensCount": "{{tokens}} token", + "sessionsCount_one": "{{count}} phiên", + "sessionsCount_other": "{{count}} phiên", + "ariaLabel": "Sơ đồ luồng ủy quyền mô hình", + "tooltip": { + "mainModel": "Mô hình chính", + "subagentModel": "Mô hình Subagent", + "agentRuns": "Lượt chạy Agent", + "shareOfAll": "Tỷ trọng trên tổng Agent", + "sessionsOnModel": "Phiên dùng mô hình này", + "totalTokens": "Tổng token", + "family": { + "opus": "Opus - bậc khả năng cao nhất; tốt cho lý luận phức tạp, ngữ cảnh dài nhất, đắt nhất theo token.", + "sonnet": "Sonnet - bậc cân bằng; mặc định tốt cho hầu hết quy trình code và dùng tool.", + "haiku": "Haiku - bậc nhanh nhất, rẻ nhất; phù hợp cho Subagent ngắn, nhạy cảm độ trễ.", + "other": "Mô hình ngoài Anthropic hoặc không nhận dạng được - không thuộc bộ Opus/Sonnet/Haiku tiêu chuẩn." + }, + "lines": { + "main": "Các đường bên phải cho thấy mô hình Subagent mà mô hình chính này đã ủy quyền.", + "sub": "Các đường bên trái cho thấy mô hình chính nào đã ủy quyền cho mô hình Subagent này." + } + } + }, + "errorPropagation": { + "title": "Bản đồ lan truyền lỗi", + "subtitle": "Điểm tập trung lỗi theo độ sâu phân cấp Agent", + "noErrors": "Không ghi nhận lỗi", + "allSuccess": "Tất cả phiên đã hoàn tất thành công", + "sessionsHadErrors": " phiên gặp lỗi", + "sessionsErrorSummary": "{{errorSessions}} trên {{totalSessions}} phiên gặp lỗi", + "agentErrors": " lỗi cấp Agent trong toàn bộ phân cấp", + "sessionErrorsOnly": "Chỉ có lỗi ở cấp phiên", + "errorsByDepth": "Lỗi theo độ sâu phân cấp", + "errorProneTypes": "Loại Agent dễ lỗi", + "apiSessionErrors": "Lỗi API & phiên", + "depthLabels": { + "sessionMain": "Phiên / Agent chính", + "directSubagent": "Subagent trực tiếp", + "nested": "Lồng nhau (độ sâu 2)", + "deep": "Sâu (độ sâu 3+)" + } + }, + "complexity": { + "title": "Biểu đồ phân tán độ phức tạp phiên", + "subtitle": "Thời lượng vs số Agent vs token - kích thước bong bóng = lượng token", + "noData": "Chưa có dữ liệu độ phức tạp phiên", + "noDataDesc": "Phiên sẽ xuất hiện ở đây sau khi hoàn tất với hoạt động Agent.", + "duration": "Thời lượng", + "agentCount": "Số Agent", + "ariaLabel": "Biểu đồ phân tán độ phức tạp phiên", + "tooltip": { + "duration": "Thời lượng:", + "agents": "Agent:", + "subagents": "Subagent:", + "tokens": "Token:", + "model": "Mô hình:" + } + }, + "compaction": { + "title": "Phân tích tác động nén ngữ cảnh", + "subtitle": "Cách nén ngữ cảnh phân bố trên các phiên của bạn", + "help": "Khi cửa sổ ngữ cảnh của một phiên đầy, Claude Code nén phần lịch sử cũ thành bản tóm tắt. Mỗi lần nén dưới đây là một sự kiện như vậy.", + "noData": "Chưa ghi nhận nén ngữ cảnh", + "totalCompactions": "Tổng số lần nén", + "sessionsAffected": "Phiên bị ảnh hưởng", + "ofTotal": "trên {{total}}", + "avgPerSession": "TB / phiên bị ảnh hưởng", + "peakSession": "Nhiều nhất trong một phiên", + "tokensRecovered": "Token thu hồi", + "distribution": "Số phiên theo số lần nén", + "xAxis": "Số lần nén mỗi phiên", + "yAxis": "Số phiên", + "summary": "{{affected}} trên {{total}} phiên ({{pct}}%) đã dùng nén ngữ cảnh.", + "tokensFreed": "Nén đã giải phóng ~{{tokens}} token ngữ cảnh.", + "ariaLabel": "Số phiên theo số lần nén", + "tipTitle": "Nén {{count}} lần", + "tipDetail": "{{sessions}} phiên · {{pct}}% số phiên bị ảnh hưởng" + }, + "skillUsage": { + "title": "Mức dùng skill & plugin", + "subtitle": "Các phiên làm việc thực sự dùng skill nào", + "plugins": "Plugin", + "skills": "Skill", + "empty": "Chưa ghi nhận lần gọi skill nào.", + "calls_one": "{{count}} lần gọi", + "calls_other": "{{count}} lần gọi", + "sessions_one": "{{count}} phiên", + "sessions_other": "{{count}} phiên", + "pluginBreakdown": "{{skills}} skill được dùng · {{skillCalls}} lần gọi skill · {{mcpCalls}} lần gọi tool MCP" } } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 4c739199..ac2adbd2 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -390,9 +390,11 @@ import type { LaneRuntime, ModelPricing, NamedLock, + PipelineStats, Session, SessionDrillIn, SessionStats, + SkillUsage, Stats, TranscriptListResult, TranscriptResult, @@ -1122,6 +1124,17 @@ export const api = { */ session: (id: string) => request(`/workflows/session/${encodeURIComponent(id)}`), + /** + * GET /api/workflows/skill-usage — how often each skill was invoked and how + * much each plugin is used (its skills plus its own MCP tools). + * + * @param status Optional lifecycle filter; "all" (or omitted) means no filter. + * @returns {@link SkillUsage} — per-skill rows and per-plugin totals. + */ + skillUsage: (status?: string) => + request( + `/workflows/skill-usage${status && status !== "all" ? `?status=${status}` : ""}` + ), // Workflow-tool runs (issue #167) - fleets ingested from on-disk journals. // These two endpoints cover fleets that emit no hooks: the server reads their // run journals off disk (see server/lib/workflow-ingest.js) instead of the @@ -1908,6 +1921,12 @@ export const api = { request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>( "/lanes/pipelines" ), + /** + * GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every + * lane and every archived feature. + * @returns `{ pipelines }` — see {@link PipelineStats}. + */ + pipelineStats: () => request("/lanes/pipeline-stats"), /** * GET /api/lanes/branches — a candidate source repo's local branches. * @param repo Absolute path to an existing git repository. diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index b0c50121..8e3a215e 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2465,6 +2465,53 @@ export interface LaneCounts { dead: number; } +/** + * How often one pipeline node was actually passed, summed over every lane and + * every archived feature. `passed + failed === entered`; `withEvidence` is the + * subset of passes that left an artifact, so a claim never reads as a proof. + */ +export interface PipelineNodeStat { + id: string; + label: string; + icon: string; + gate: boolean; + entered: number; + passed: number; + withEvidence: number; + failed: number; +} + +/** Per-node pass counts, grouped by pipeline template (busiest first). */ +export interface PipelineStats { + pipelines: { + id: string; + name: string; + /** Lanes + archived features recorded against this template. */ + runs: number; + nodes: PipelineNodeStat[]; + }[]; +} + +/** Skill invocation counts and the per-plugin totals derived from them. */ +export interface SkillUsage { + skills: { + /** Skill name as invoked, e.g. `ccam:ship-feature` or a bare `daily-report`. */ + name: string; + /** Owning plugin, or null for a built-in/personal skill. */ + plugin: string | null; + count: number; + sessions: number; + }[]; + plugins: { + name: string; + /** Distinct skills of this plugin that were used at least once. */ + skills: number; + skillCalls: number; + /** Calls to the plugin's own MCP tools. */ + mcpCalls: number; + }[]; +} + // ───── Status presentation lookup (agents) ───── /** diff --git a/client/src/pages/Workflows.tsx b/client/src/pages/Workflows.tsx index 9341e184..80f8cfad 100644 --- a/client/src/pages/Workflows.tsx +++ b/client/src/pages/Workflows.tsx @@ -1,11 +1,12 @@ /** * @file Workflows.tsx - * @description Workflows page: this dashboard's own lane pipelines first, then the - * few Claude Code orchestration views that stay readable and interactive — headline - * stats, Workflow-tool runs, the spawning DAG, the tool flow, and the per-session - * drill-in. The deeper analytics (collaboration network, subagent effectiveness, - * patterns, model delegation, error propagation, concurrency, complexity, - * compaction) were dropped in issue #6 as too specialized to act on. + * @description Workflows page. Opens on this dashboard's OWN pipelines — per-node + * pass rates across every lane, and which skills/plugins actually get used — then + * the Claude Code orchestration analytics: headline stats, Workflow-tool runs, the + * spawning DAG, tool flow, patterns, model delegation, error propagation, session + * complexity, compaction impact and the per-session drill-in. Issue #6 dropped the + * agent collaboration network and the subagent effectiveness scorecards as too + * specialized to act on. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= @@ -31,9 +32,15 @@ * - `../lib/types` * - `../components/workflows/WorkflowStats` * - `../components/workflows/LanePipelinePanel` + * - `../components/workflows/SkillUsagePanel` * - `../components/workflows/WorkflowRunsPanel` * - `../components/workflows/OrchestrationDAG` * - `../components/workflows/ToolExecutionFlow` + * - `../components/workflows/WorkflowPatterns` + * - `../components/workflows/ModelDelegationFlow` + * - `../components/workflows/ErrorPropagationMap` + * - `../components/workflows/SessionComplexityScatter` + * - `../components/workflows/CompactionImpact` * - `../components/workflows/SessionDrillIn` * * ## Public surface @@ -76,9 +83,15 @@ import type { WorkflowData, WSMessage } from "../lib/types"; import { WorkflowStats } from "../components/workflows/WorkflowStats"; import { OrchestrationDAG } from "../components/workflows/OrchestrationDAG"; import { ToolExecutionFlow } from "../components/workflows/ToolExecutionFlow"; +import { WorkflowPatterns } from "../components/workflows/WorkflowPatterns"; +import { ModelDelegationFlow } from "../components/workflows/ModelDelegationFlow"; +import { ErrorPropagationMap } from "../components/workflows/ErrorPropagationMap"; +import { SessionComplexityScatter } from "../components/workflows/SessionComplexityScatter"; +import { CompactionImpact } from "../components/workflows/CompactionImpact"; import { SessionDrillIn } from "../components/workflows/SessionDrillIn"; import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel"; import { LanePipelinePanel } from "../components/workflows/LanePipelinePanel"; +import { SkillUsagePanel } from "../components/workflows/SkillUsagePanel"; type StatusFilter = "all" | "active" | "completed"; @@ -198,10 +211,21 @@ export function Workflows() { {/* Stats Row */} - {/* This dashboard's own pipelines - lane strip + the selected lane's map */} -
- -
+ {/* Section 1 + 2: this dashboard's own pipelines, and what drives them */} +
+
+ +
+ +
+ +
+
{/* Workflow-tool runs (issue #167) - fleets ingested from on-disk journals */}
@@ -215,9 +239,9 @@ export function Workflows() {
- {/* Section 2: Agent Orchestration DAG */} + {/* Section 3: Agent Orchestration DAG */}
- {/* Section 3: Tool Execution Flow */} + {/* Section 4: Tool Execution Flow */}
- {/* Section 4: Session Drill-In */} + {/* Section 5: Workflow Patterns */}
+ {}} /> +
+ + {/* Section 6 + 7: Two Column */} +
+
+ +
+ +
+ +
+
+ + {/* Section 8 + 9: Two Column */} +
+
+ +
+ +
+ +
+
+ + {/* Section 10: Session Drill-In */} +
Workflows 1`] = `
- - 1 - -

- Lane Pipelines -

- + 1 + +

+ Lane Pipeline Pass Rates +

+ +
+
- +

+ No lane has recorded a stage yet. Create one with + + + ccam lanes add --cwd $(pwd) + +

+
-

- No lanes yet. Create one from a working directory: - - - ccam lanes add --cwd $(pwd) - -

+
+ + 2 + +

+ Skill & Plugin Usage +

+ +
+ +
+
+

+ No skill invocation recorded yet. +

+
Workflows 1`] = ` - 2 + 3

Workflows 1`] = ` - 3 + 4

Workflows 1`] = ` - 4 + 5 + +

+ Detected Workflow Patterns +

+ +
+ +
+
+
+

+ Workflow Patterns +

+
+
+ + + +
+

+ No patterns detected yet +

+

+ Patterns appear once multiple sessions share the same agent sequence. +

+
+
+
+
+
+
+
+
+ + 6 + +

+ Model Delegation Flow +

+ +
+ +
+
+
+ + + No model delegation data available + +
+
+
+
+
+
+ + 7 + +

+ Error Propagation Map +

+ +
+ +
+
+
+
+ + + + +
+ + No errors recorded + + + All sessions completed successfully + +
+
+
+
+
+
+
+
+ + 8 + +

+ Session Complexity Scatter +

+ +
+ +
+
+
+
+ + + + + +
+

+ No session complexity data +

+

+ Sessions will appear here once they complete with agent activity. +

+
+
+
+
+
+
+ + 9 + +

+ Compaction Impact Analysis +

+ +
+ +
+
+
+ + + No compactions recorded + +
+
+
+
+
+
+
+ + 10

{ workflows: { get: r(emptyWorkflow), session: r({}), + skillUsage: r({ skills: [], plugins: [] }), runs: r({ runs: [sampleWorkflowRun], total: 1, @@ -388,6 +389,7 @@ vi.mock("../../lib/api", async (importOriginal) => { update: r({ lane: {} }), stage: r({ lane: {} }), pipelines: r({ pipelines: [] }), + pipelineStats: r({ pipelines: [] }), // A realistic shape: `{}` would crash blockingReason on `blocked` if any // screen ever opened the destructive modal. preflight: r({ diff --git a/docs/API.md b/docs/API.md index 18230d7b..fa671029 100644 --- a/docs/API.md +++ b/docs/API.md @@ -567,6 +567,78 @@ Converge the clone-root `proof/` directory (if it exists at `/proof`) - **200** — success - **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }` — lane doesn't exist +#### Pipeline pass statistics + +```http +GET /api/lanes/pipeline-stats +``` + +How often each pipeline node was actually passed, aggregated over every lane +**and** every archived feature (an archived feature is a finished run of a +pipeline, so it carries most of the history once a lane starts its next +feature). No parameters; grouped by pipeline template, busiest first. + +```json +{ + "pipelines": [ + { + "id": "default", + "name": "Default feature pipeline", + "runs": 4, + "nodes": [ + { "id": "plan", "label": "plan", "icon": "🧭", "gate": false, + "entered": 4, "passed": 4, "withEvidence": 4, "failed": 0 } + ] + } + ] +} +``` + +- **`runs`** — lanes plus archived features recorded against this template. +- **`entered`** — stage records that resolve onto this node (aliases included). +- **`failed`** — records whose `result` is `"fail"`; **`passed`** is the rest, + so `passed + failed === entered`. +- **`withEvidence`** — the subset of passes that carry evidence. Tracked apart + from `passed` because a claim and a proof are not the same fact — the same + distinction the pipeline map draws between `done` and `passed-no-evidence`. + +**Status codes:** **200** always (an empty `pipelines` array when no lane has +recorded a stage yet). + +### Workflows + +#### Skill and plugin usage + +```http +GET /api/workflows/skill-usage?status=active|completed +``` + +Skill invocations counted from `Skill` tool events — `PreToolUse` rows only, +since every call also emits a `PostToolUse` and counting both would double +every number. `status` filters by session lifecycle, same convention as +`GET /api/workflows`; omit it (or pass `all`) for no filter. + +```json +{ + "skills": [ + { "name": "ccam:ship-feature", "plugin": "ccam", "count": 12, "sessions": 5 }, + { "name": "daily-report", "plugin": null, "count": 3, "sessions": 3 } + ], + "plugins": [ + { "name": "ccam", "skills": 1, "skillCalls": 12, "mcpCalls": 4 } + ] +} +``` + +- **`skills`** — top 30 by call count. `plugin` is the `plugin:skill` prefix, or + `null` for a built-in or personal skill. +- **`plugins`** — per-plugin totals: distinct skills used, their calls, and + calls to the MCP tools that plugin ships (`mcp__plugin____*`), + so a plugin used only through MCP does not report zero. + +**Status codes:** **200** always; **500** with `{ "error": { "message" } }` on a +query failure. + ### Locks diff --git a/docs/LANES.md b/docs/LANES.md index 031f795d..b23f4c8f 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -464,6 +464,24 @@ The lane is resolved from the working directory, so no id is needed (pass one to Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the switch warns when that happens (both CLI and UI), and the next `ccam stage` fixes it. +### Aggregate pass rates across lanes + +`GET /api/lanes/pipeline-stats` answers the other question about node states — +not "where is this lane now" but "how often does this stage actually pass". It +counts every stage record of every lane **and** every archived feature, grouped +by pipeline template: + +- `entered` — records that resolve onto the node (aliases included). +- `failed` — `result: "fail"`; `passed` is the rest, so `passed + failed === entered`. +- `withEvidence` — the passes that carry evidence, kept apart from `passed` + because a claim and a proof are not the same fact (the same line the five node + states draw between `done` and `passed-no-evidence`). + +Archived features matter here: a lane's own `stages` only holds its current +feature, so without them the history would reset every time a lane starts new +work. The Workflows page renders this as one bar per node (see +`client/src/components/workflows/LanePipelinePanel.tsx`). + ## Attaching a real terminal to a lane `ccam lanes shell` attaches a real terminal to the exact tmux session the dashboard's Start/Resume buttons use for this lane (`ccam-lane-`), creating it if it doesn't exist yet. Type `claude` inside it like any normal terminal session — the dashboard's Workspace terminal view is just another client attached to the same tmux session, so both stay in sync live. diff --git a/server/__tests__/workflows-usage-api.test.js b/server/__tests__/workflows-usage-api.test.js new file mode 100644 index 00000000..d0b3a4b4 --- /dev/null +++ b/server/__tests__/workflows-usage-api.test.js @@ -0,0 +1,162 @@ +/** + * @file Tests for the two aggregate endpoints the Workflows page reads: + * GET /api/lanes/pipeline-stats (how often each pipeline node was passed, + * over lanes AND archived features) and GET /api/workflows/skill-usage + * (per-skill and per-plugin call counts). Both are pure reporting endpoints, + * so the risks are miscounting — double-counting a tool call that emits both a + * Pre and a Post event, or losing an archived feature's history. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); +const http = require("node:http"); + +const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-usage-api-")); +process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); +process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); + +const { createApp } = require("../index"); +const lanesLib = require("../lib/lanes"); +const { db, stmts } = require("../db"); + +let server; +let PORT; + +before(async () => { + const app = createApp(); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + PORT = server.address().port; +}); +after(async () => { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(SUITE_ROOT, { recursive: true, force: true }); +}); + +function get(urlPath) { + return new Promise((resolve, reject) => { + const req = http.request( + { method: "GET", hostname: "127.0.0.1", port: PORT, path: urlPath }, + (res) => { + let raw = ""; + res.on("data", (c) => (raw += c)); + res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(raw) })); + } + ); + req.on("error", reject); + req.end(); + }); +} + +let laneSeq = 0; +function makeLane() { + laneSeq += 1; + const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`); + fs.mkdirSync(cwd, { recursive: true }); + return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" }); +} + +function setStages(laneId, stages) { + db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId); +} + +function node(pipelineStats, pipelineId, nodeId) { + const p = pipelineStats.pipelines.find((x) => x.id === pipelineId); + return p && p.nodes.find((n) => n.id === nodeId); +} + +describe("GET /api/lanes/pipeline-stats", () => { + it("counts a pass with evidence apart from a pass without one, and a fail as neither", async () => { + const lane = makeLane(); + setStages(lane.id, { + plan: { enteredAt: "2026-01-01T00:00:00Z", evidence: "plan.md", result: null }, + implement: { enteredAt: "2026-01-01T01:00:00Z", evidence: null, result: null }, + tests: { enteredAt: "2026-01-01T02:00:00Z", evidence: null, result: "fail" }, + }); + + const res = await get("/api/lanes/pipeline-stats"); + assert.equal(res.status, 200); + + const plan = node(res.body, "default", "plan"); + assert.deepEqual( + { entered: plan.entered, passed: plan.passed, withEvidence: plan.withEvidence }, + { entered: 1, passed: 1, withEvidence: 1 } + ); + + const implement = node(res.body, "default", "implement"); + assert.deepEqual( + { passed: implement.passed, withEvidence: implement.withEvidence }, + { passed: 1, withEvidence: 0 } + ); + + const tests = node(res.body, "default", "tests"); + assert.deepEqual( + { entered: tests.entered, passed: tests.passed, failed: tests.failed }, + { entered: 1, passed: 0, failed: 1 } + ); + }); + + it("leaves a node never reached at zero", async () => { + const res = await get("/api/lanes/pipeline-stats"); + assert.equal(node(res.body, "default", "ship").entered, 0); + }); + + it("keeps counting an archived feature's stages after the lane moves on", async () => { + const lane = makeLane(); + db.prepare( + `INSERT INTO lane_features (lane_id, slug, pipeline, stage, stages, archived_at) + VALUES (?, 'old', 'default', 'done', ?, '2026-01-02T00:00:00Z')` + ).run(lane.id, JSON.stringify({ gate: { enteredAt: "x", evidence: "GO", result: null } })); + + const res = await get("/api/lanes/pipeline-stats"); + const gate = node(res.body, "default", "gate"); + assert.equal(gate.passed, 1); + assert.equal(gate.withEvidence, 1); + }); +}); + +describe("GET /api/workflows/skill-usage", () => { + before(() => { + stmts.insertSession.run("usage-s1", "usage", "active", "/tmp/usage", null, null); + const ev = (type, tool, data) => + stmts.insertEvent.run("usage-s1", null, type, tool, "", JSON.stringify(data)); + + // Two full calls of the same skill: each emits Pre + Post, so a naive + // COUNT(*) would report four. + for (let i = 0; i < 2; i += 1) { + ev("PreToolUse", "Skill", { tool_input: { skill: "ccam:ship-feature" } }); + ev("PostToolUse", "Skill", { tool_input: { skill: "ccam:ship-feature" } }); + } + ev("PreToolUse", "Skill", { tool_input: { skill: "daily-report" } }); + ev("PreToolUse", "mcp__plugin_ccam_ccam__dashboard_health_check", {}); + ev("PostToolUse", "mcp__plugin_ccam_ccam__dashboard_health_check", {}); + }); + + it("counts one call per invocation, not one per hook event", async () => { + const res = await get("/api/workflows/skill-usage"); + assert.equal(res.status, 200); + const ship = res.body.skills.find((s) => s.name === "ccam:ship-feature"); + assert.equal(ship.count, 2); + assert.equal(ship.sessions, 1); + }); + + it("attributes a prefixed skill to its plugin and leaves a bare one unattributed", async () => { + const res = await get("/api/workflows/skill-usage"); + assert.equal(res.body.skills.find((s) => s.name === "ccam:ship-feature").plugin, "ccam"); + assert.equal(res.body.skills.find((s) => s.name === "daily-report").plugin, null); + }); + + it("adds a plugin's own MCP tool calls to its totals", async () => { + const res = await get("/api/workflows/skill-usage"); + const ccam = res.body.plugins.find((p) => p.name === "ccam"); + assert.deepEqual( + { skills: ccam.skills, skillCalls: ccam.skillCalls, mcpCalls: ccam.mcpCalls }, + { skills: 1, skillCalls: 2, mcpCalls: 1 } + ); + }); +}); diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 7f32ba17..1b16da3c 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -14,7 +14,13 @@ const os = require("node:os"); const path = require("node:path"); const { db } = require("../db"); const lanesLib = require("../lib/lanes"); -const { listPipelines, getPipeline, nodeStates, progressPct } = require("../lib/pipelines"); +const { + listPipelines, + getPipeline, + nodeStates, + progressPct, + stageRecords, +} = require("../lib/pipelines"); const laneFeatures = require("../lib/lane-features"); const proofLib = require("../lib/proof"); const { broadcast } = require("../websocket"); @@ -128,6 +134,73 @@ router.get("/", (_req, res) => { // Registered before "/:id" so the literal path is not swallowed by the param. router.get("/pipelines", (_req, res) => res.json({ pipelines: listPipelines() })); +/** + * How often each pipeline node has actually been passed, aggregated over every + * lane AND every archived feature — an archived feature is a finished run of a + * pipeline, so dropping it would throw away most of the history the moment a + * lane starts its next feature. + * + * A node is counted `entered` when a stage record resolves onto it; `failed` + * when that record says `result: "fail"`; `passed` otherwise; `withEvidence` + * for the subset of passes that left an artifact behind. Evidence is tracked + * separately rather than folded into the pass rate, because "claimed" and + * "proved" are not the same fact — the same distinction the pipeline map draws + * between `done` and `passed-no-evidence`. Also registered before "/:id". + */ +router.get("/pipeline-stats", (_req, res) => { + const rows = [ + ...db.prepare("SELECT pipeline, stages FROM lanes").all(), + ...db.prepare("SELECT pipeline, stages FROM lane_features").all(), + ]; + + const acc = new Map(); + for (const row of rows) { + let stages; + try { + stages = JSON.parse(row.stages); + } catch { + continue; // a corrupt blob costs one row, never the whole report + } + const pipeline = getPipeline(row.pipeline); + let entry = acc.get(pipeline.id); + if (!entry) { + entry = { + id: pipeline.id, + name: pipeline.name || pipeline.id, + runs: 0, + nodes: pipeline.nodes.map((n) => ({ + id: n.id, + label: n.label, + icon: n.icon, + gate: n.gate, + entered: 0, + passed: 0, + withEvidence: 0, + failed: 0, + })), + }; + acc.set(pipeline.id, entry); + } + entry.runs += 1; + const records = stageRecords(pipeline, stages); + for (const [idx, rec] of records) { + const node = entry.nodes[idx]; + if (!node) continue; + node.entered += 1; + if (rec && rec.result === "fail") node.failed += 1; + else { + node.passed += 1; + if (rec && rec.evidence) node.withEvidence += 1; + } + } + } + + // Busiest pipeline first: the one with the most recorded runs is the one the + // reader most likely came to look at. + const pipelines = [...acc.values()].sort((a, b) => b.runs - a.runs); + res.json({ pipelines }); +}); + /** * Local branches of a candidate source repo, for the "Add lane" picker: pick * a repo, then pick which branch to fork the new worktree from, instead of diff --git a/server/routes/workflows.js b/server/routes/workflows.js index 1e60924e..f916f253 100644 --- a/server/routes/workflows.js +++ b/server/routes/workflows.js @@ -39,6 +39,82 @@ router.get("/", (req, res) => { } }); +/** + * How much each skill and each plugin is actually used. + * + * Skill calls are read off `Skill` tool events, one row per invocation: + * `PreToolUse` only, because every call also emits a `PostToolUse` and counting + * both would double every number. The skill's own name carries its plugin as a + * `plugin:skill` prefix; a bare name is a built-in or personal skill and is + * grouped under `null`. + * + * A plugin's total also includes calls to the MCP tools it ships + * (`mcp__plugin___`) — a plugin used entirely through its MCP + * server would otherwise report zero usage. + */ +router.get("/skill-usage", (req, res) => { + try { + const sf = sessionIdFilter(req.query.status || null); + + const skills = db + .prepare( + `SELECT json_extract(data, '$.tool_input.skill') AS name, + COUNT(*) AS count, + COUNT(DISTINCT session_id) AS sessions + FROM events + WHERE tool_name = 'Skill' AND event_type = 'PreToolUse' + AND json_extract(data, '$.tool_input.skill') IS NOT NULL${sf.clause} + GROUP BY name ORDER BY count DESC LIMIT 30` + ) + .all(...sf.params) + .map((r) => { + const i = r.name.indexOf(":"); + return { ...r, plugin: i > 0 ? r.name.slice(0, i) : null }; + }); + + const mcpCalls = db + .prepare( + `SELECT tool_name, COUNT(*) AS count FROM events + WHERE tool_name LIKE 'mcp__plugin\\_%' ESCAPE '\\' + AND event_type = 'PreToolUse'${sf.clause} + GROUP BY tool_name` + ) + .all(...sf.params); + + const byPlugin = new Map(); + const bump = (name, field, n) => { + const e = byPlugin.get(name) || { name, skills: 0, skillCalls: 0, mcpCalls: 0 }; + e[field] += n; + byPlugin.set(name, e); + }; + for (const s of skills) { + if (!s.plugin) continue; + bump(s.plugin, "skills", 1); + bump(s.plugin, "skillCalls", s.count); + } + for (const m of mcpCalls) { + // mcp__plugin____. Both halves may contain + // underscores, so the split is only unambiguous when the plugin names its + // server after itself — the overwhelmingly common case (`ccam_ccam`, + // `vercel_vercel`), which the dedupe below collapses back to one name. + // ponytail: a plugin whose server has its own name reports under + // "_"; read the real pairing off the MCP config if that + // ever matters. + const seg = m.tool_name.slice("mcp__plugin_".length).split("__")[0]; + const doubled = seg.match(/^(.+)_\1$/); + const plugin = doubled ? doubled[1] : seg; + if (plugin) bump(plugin, "mcpCalls", m.count); + } + const plugins = [...byPlugin.values()].sort( + (a, b) => b.skillCalls + b.mcpCalls - (a.skillCalls + a.mcpCalls) + ); + + res.json({ skills, plugins }); + } catch (err) { + res.status(500).json({ error: { message: err.message } }); + } +}); + // ── GET /session/:id — Single session drill-in ── router.get("/session/:id", (req, res) => { try {