diff --git a/.agents/skills/deliberation/SKILL.md b/.agents/skills/deliberation/SKILL.md index 44c095d..476715a 100644 --- a/.agents/skills/deliberation/SKILL.md +++ b/.agents/skills/deliberation/SKILL.md @@ -164,7 +164,8 @@ These apply to every MCP host, not just Claude Code: open the printed `http://127.0.0.1:/?t=` URL. It is a read-only browser view of live and past runs as state graphs, with config, provider health, stats, and an Analyzer (drop candidates, per-project health, request size vs timeouts; every run records its - calling project from the tool's `cwd`). `deliberation-mcp dashboard --stop` stops it. Loopback + calling project from the tool's `cwd`, grouped by git remote `org/repo` with each checkout + as a workspace). `deliberation-mcp dashboard --stop` stops it. Loopback only, token-protected; `dashboard.capture` is `metadata` (default) or `content` (prompts and responses, secret-scrubbed), and PII is redacted in the browser unless `dashboard.showPII`. Needs a browser on the same machine. OFF by default. diff --git a/.gemini/skills/deliberation/SKILL.md b/.gemini/skills/deliberation/SKILL.md index 44c095d..476715a 100644 --- a/.gemini/skills/deliberation/SKILL.md +++ b/.gemini/skills/deliberation/SKILL.md @@ -164,7 +164,8 @@ These apply to every MCP host, not just Claude Code: open the printed `http://127.0.0.1:/?t=` URL. It is a read-only browser view of live and past runs as state graphs, with config, provider health, stats, and an Analyzer (drop candidates, per-project health, request size vs timeouts; every run records its - calling project from the tool's `cwd`). `deliberation-mcp dashboard --stop` stops it. Loopback + calling project from the tool's `cwd`, grouped by git remote `org/repo` with each checkout + as a workspace). `deliberation-mcp dashboard --stop` stops it. Loopback only, token-protected; `dashboard.capture` is `metadata` (default) or `content` (prompts and responses, secret-scrubbed), and PII is redacted in the browser unless `dashboard.showPII`. Needs a browser on the same machine. OFF by default. diff --git a/POWER.md b/POWER.md index e9bbac5..7f020b8 100644 --- a/POWER.md +++ b/POWER.md @@ -167,7 +167,8 @@ These apply to every MCP host, not just Claude Code: open the printed `http://127.0.0.1:/?t=` URL. It is a read-only browser view of live and past runs as state graphs, with config, provider health, stats, and an Analyzer (drop candidates, per-project health, request size vs timeouts; every run records its - calling project from the tool's `cwd`). `deliberation-mcp dashboard --stop` stops it. Loopback + calling project from the tool's `cwd`, grouped by git remote `org/repo` with each checkout + as a workspace). `deliberation-mcp dashboard --stop` stops it. Loopback only, token-protected; `dashboard.capture` is `metadata` (default) or `content` (prompts and responses, secret-scrubbed), and PII is redacted in the browser unless `dashboard.showPII`. Needs a browser on the same machine. OFF by default. diff --git a/README.md b/README.md index cda61b5..712bba9 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ When three models argue, the real bug reveals itself. Round 1 = independent top ![Dashboard: a converged 3-round /consensus run with the arbiter and six panel voices on one timeline](assets/dashboard.png) -The dashboard (`/deliberation:dashboard`, opt-in with `"dashboard": { "enabled": true }`) is a read-only page on `127.0.0.1`. **Live** follows runs as they happen, **Runs** keeps the history (with the project each run came from, and a project filter), **Config** shows the effective config, **Stats** shows usage and provider health, and **Analyzer** shows which models agree more than they add (drop candidates), which projects fail or run slow, and whether request size predicts latency and timeouts, with a suggested timeout. For a run you get: +The dashboard (`/deliberation:dashboard`, opt-in with `"dashboard": { "enabled": true }`) is a read-only page on `127.0.0.1`. **Live** follows runs as they happen, **Runs** keeps the history (with the repo and workspace each run came from, grouped by git remote `org/repo`, and a project filter that picks a whole repo or one workspace), **Config** shows the effective config, **Stats** shows usage and provider health, and **Analyzer** shows which models agree more than they add (drop candidates), which projects fail or run slow, and whether request size predicts latency and timeouts, with a suggested timeout. For a run you get: - the header: tool, elapsed time, status, round, tokens, errors, and expert - the phase strip: blind, peers, adjudicate, converged or revise, each with its time offset @@ -348,7 +348,7 @@ Full setup and configuration reference lives in **[SETUP.md](SETUP.md)**. It cov - **Timeouts** - `providers.defaults.timeout` raises the per-call ceiling for every provider at once; `providers..timeout` overrides one, and a pinned model's `models..timeout` still wins. A host cap (`MCP_TOOL_TIMEOUT`, set by Claude Code on the web) clamps all of them and is named in the timeout message. A rate-limited (HTTP 429) call is retried once, honoring the upstream's `Retry-After` - **Debug log** - opt-in latency / token / voting trace - **Session persistence** - opt-in on-disk run history (incl. the host-driven `/consensus` loop) and the `session-*` tools; `sessions.captureText` (default off) additionally stores provider response bodies (scrubbed) -- **Dashboard** - opt-in local, read-only browser view of live and past runs as state graphs, with config, provider health, and usage stats. `dashboard.capture` picks metadata only (default) or full prompts and responses; secrets are scrubbed on write and PII is redacted on serve unless `dashboard.showPII` is on. Loopback only, token-protected. Every run records its project (the git repo it was called from), every provider call its request size and the timeout it actually got, which the Analyzer tab turns into drop candidates and timeout advice. Start it with `/deliberation:dashboard` or `deliberation-mcp dashboard`; restart it with `/deliberation:dashboard restart` or `npm run dashboard:restart`, stop it with `deliberation-mcp dashboard --stop` +- **Dashboard** - opt-in local, read-only browser view of live and past runs as state graphs, with config, provider health, and usage stats. `dashboard.capture` picks metadata only (default) or full prompts and responses; secrets are scrubbed on write and PII is redacted on serve unless `dashboard.showPII` is on. Loopback only, token-protected. Every run records its project (grouped by git remote `org/repo`, with the checkout it was called from as the workspace), every provider call its request size and the timeout it actually got, which the Analyzer tab turns into drop candidates and timeout advice. Start it with `/deliberation:dashboard` or `deliberation-mcp dashboard`; restart it with `/deliberation:dashboard restart` or `npm run dashboard:restart`, stop it with `deliberation-mcp dashboard --stop` Config history records sanitized effective snapshots and run provenance when telemetry is enabled. Dashboard Stats shares analyze time/config filters; Config diff --git a/SETUP.md b/SETUP.md index 547477a..ef6b40b 100644 --- a/SETUP.md +++ b/SETUP.md @@ -329,8 +329,11 @@ same machine as the MCP server, so it does not work from Claude Code on the web. 3. Run `/consensus` or `/ask-all` and watch the graph fill in. Each run records the project it was called from: the git repo of the tool call's `cwd` -(or the MCP server's directory), with worktrees grouped under their main repo. The Runs -tab shows it as a column with a project filter; the Analyzer tab uses the same filter. +(or the MCP server's directory), grouped by its git remote as `org/repo`, so every clone +and worktree of a repo lands in one group, with the checkout dir as the workspace (shown as +`acme/app (/tmp/app-review)`). The Runs tab shows it as a column; its project filter is +a tree where you pick a whole repo or one workspace, and the Analyzer tab uses the same +filter. Restart or stop it: diff --git a/TECHNICAL.md b/TECHNICAL.md index bc1539d..776d8c4 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -1830,7 +1830,7 @@ and the journal is the history. | `server/dashboard/ui/telemetry.js` | Pure debate convergence trajectory and provider latency breakdown analytics | | `server/dashboard/analyzer.js` | Pure Analyzer report over journal runs: models that agree more than they add, per-project health, request size vs latency and timeouts (see Analyzer) | | `server/dashboard/stop.js` | `dashboard --stop`: signals the pidfile's pid only when it is provably the dashboard | -| `core/project.js` | `resolveProject(dir)`: the calling project (git repo, worktrees grouped under their main repo) for `run_start` | +| `core/project.js` | `resolveProject(dir)`: the calling project (grouped by git remote `org/repo`, then the checkout as a workspace) for `run_start`; `normalizeProject` upgrades runs recorded before workspaces | ### Configuration @@ -1861,7 +1861,7 @@ debug log, whose `ALLOWED_KEYS` whitelist is unchanged and still excludes all co | Kind | Metadata fields | Content fields (`capture: "content"` only) | |------|-----------------|---------------------------------------------| -| `run_start` | `tool`, `pid`, `procStartedAt`, `expert`, `workflow` (`single` \| `fanout` \| `consensus-step` \| `consensus`), `providers[]`, `project` (`{id, name, root}`) | `prompt` | +| `run_start` | `tool`, `pid`, `procStartedAt`, `expert`, `workflow` (`single` \| `fanout` \| `consensus-step` \| `consensus`), `providers[]`, `project` (`{id, name, root, ws}`) | `prompt` | | `state` | `state`, `round`, `status`, `verdicts[]` (`{provider, verdict, categories[]}`, on adjudicate) | none | | `call_start` | `callId`, `provider`, `model`, `role` (`peer` \| `arbiter` \| `blind` \| `single`), `round`, `timeoutMs`, `reasoningEffort`, `promptChars`, `fileCount`, `orientationFiles`, `fileBytes`, `grantedMs`, `hostCapMs`, `ceilingSource` (`own` \| `shared` \| `outer` \| `host`), `sharedBy[]`, `sharedLimitMs` | `request` | | `call_end` | `callId`, `provider`, `model`, `ms`, `usage`, `isError`, `errorKind`, `errorCode`, `verdict`, `criticalIssues[].category` | `response`, `criticalIssues[].description` | @@ -1870,19 +1870,33 @@ debug log, whose `ALLOWED_KEYS` whitelist is unchanged and still excludes all co Keys outside this whitelist are dropped at write. Content fields pass `scrubSecrets` and then `capText` (100 KB) before the write, the same privacy contract as `sessions`. The -analyzer fields are also shape-checked at write: `project` keeps only its three strings, +analyzer fields are also shape-checked at write: `project` keeps only its three strings +plus `ws` when it is 12 hex chars, the size and ceiling fields only finite numbers or null, `ceilingSource` only its four values. **`project`** is `core/project.js` `resolveProject` of the tool call's `cwd` argument, or -the server's own cwd when the call passes none. It walks up to the nearest `.git`: a main -checkout is itself; a linked worktree (a `.git` file whose gitdir has a `commondir`) is -grouped under its main repo, so `name` is the main repo's folder and `id` is shared, while -`root` stays the worktree path; a submodule (a `.git` file with no `commondir`) is its own -project; no git at all falls back to the directory. `id` is 12 hex chars of a SHA-256 of -the main git dir's real path, never a path itself. Stat and read only, no `git` spawn, -never throws. `root` is an absolute path on disk; serving masks the home dir (Redaction) -unless `showPII` is on, and the URL only ever carries the `id`. +the server's own cwd when the call passes none. It walks up to the nearest `.git` (the +checkout) and groups runs in two levels: + +- **Repo group** (`id`, `name`). The main git dir's `config` is read for the `origin` + remote, else the first remote. `name` is the remote path (`org/repo`, the full path for + nested GitLab groups) and `id` is 12 hex chars of a SHA-256 of `host/path`, so every clone, + worktree, or `/tmp` checkout of one remote shares a group. scp (`git@host:org/repo`), + `ssh://` and `https://` URLs are parsed; credentials and `.git` are dropped, and the raw URL + and host are never stored. Without a usable remote (none, or a local path or `file://`) + the group is the main git dir as before: `id` hashes its real path and `name` is the main + repo's folder. A linked worktree reads its main repo's config; a submodule is its own + repo; no git at all falls back to the directory. +- **Workspace** (`root`, `ws`). `root` is the checkout path; `ws` is 12 hex chars of a + SHA-256 of its real path. A call from a subdir counts as its checkout. + +Stat and read only, no `git` spawn, never throws. `root` is an absolute path on disk; +serving masks the home dir (Redaction) unless `showPII` is on, and the URL only ever carries +the ids. Runs recorded before `ws` existed are normalized when the dashboard reads them +(`normalizeProject`): if the recorded `root` still exists it is resolved again and joins +its remote group; otherwise the run keeps its old group and gets a `ws` from the root path. +Nothing in the journal is rewritten. **Request size** (`promptChars`, `fileCount`, `orientationFiles`, `fileBytes`) is measured on the request as `callProvider` sends it: after orientation files are attached and after @@ -1951,12 +1965,12 @@ GET and HEAD only; any other method gets 405. |-------|---------| | `/` | The page. | | `/assets/*` | Static UI files, resolved inside the UI directory; a path that escapes it is rejected. | -| `/api/runs` | Run index; filters `?q=`, `?tool=`, `?provider=`, `?status=`, `?since=`, `?project=` (`unknown` = runs with no project). While `showPII` is off, `?q=` matches the redacted prompt, so a search cannot confirm a masked value. | +| `/api/runs` | Run index; filters `?q=`, `?tool=`, `?provider=`, `?status=`, `?since=`, `?project=` (a repo group with all its workspaces; `unknown` = runs with no project), `?ws=` (one workspace; wins over `?project=`). While `showPII` is off, `?q=` matches the redacted prompt, so a search cannot confirm a masked value. | | `/api/runs/:id` | All events of one run (or the legacy record); `id` must match `^[A-Za-z0-9-]+$`. | | `/api/config` | Effective config. For every API key env var, only its name and whether it is set; credentials in URLs are stripped. | | `/api/health` | Provider health from the checks `panel` uses (`unavailable`, `needsLogin`), models, reasoning effort, `askAll` / `consensus` eligibility. | | `/api/stats` | The `analyze` report plus runs, tokens, and errors per day. Reads the debug log, so it cannot be filtered by project. | -| `/api/analyzer` | The Analyzer report (see Analyzer). `?days=` 1-365 (default 30), `?project=`; at most 2000 runs per report. | +| `/api/analyzer` | The Analyzer report (see Analyzer). `?days=` 1-365 (default 30), `?project=`, `?ws=` (12 hex; 400 otherwise); at most 2000 runs per report. | | `/api/events` | Server-Sent Events stream of new journal events. | The tailer keeps a byte offset per run file and reads only appended bytes; a partial last @@ -1996,9 +2010,12 @@ it is shown as `unconfirmed`, never as a candidate. The six categories are coars is advice; the page names the config key (`models..consensus: false` or `providers..enabled: false`) and never writes it. -**Projects.** Per `project.id`: runs, run statuses, calls, timeouts, errors, p50/p95 of -successful non-cached calls, top three error kinds. Runs without a project group under -`(unknown)`. +**Projects.** Per repo group (`project.id`): runs, run statuses, calls, timeouts, errors, +p50/p95 of successful non-cached calls, top three error kinds, and the same numbers per +workspace in `workspaces[]` (`ws`, `root`). Runs without a project group under `(unknown)`. +The page shows a group as `org/repo (path)` when it has one workspace; with more, a toggle +expands the workspace rows. Clicking a name sets the project filter, which the Runs and +Analyzer tabs share: a whole group or one workspace. **Request size vs latency.** Per provider and model, split text-only (size = `promptChars`) from file-bearing (size = `promptChars + fileBytes`; null `fileBytes` is skipped and diff --git a/core/journal.js b/core/journal.js index ead9d9c..7312853 100644 --- a/core/journal.js +++ b/core/journal.js @@ -233,7 +233,7 @@ const NUM_OR_NULL_KEYS = ["promptChars", "fileCount", "orientationFiles", "fileB function sanitizeShapes(out) { if ("project" in out) { const p = /** @type {any} */ (out.project); - if (p && typeof p === "object" && typeof p.id === "string" && typeof p.name === "string" && typeof p.root === "string") out.project = { id: p.id, name: p.name, root: p.root }; + if (p && typeof p === "object" && typeof p.id === "string" && typeof p.name === "string" && typeof p.root === "string") out.project = { id: p.id, name: p.name, root: p.root, ...(typeof p.ws === "string" && /^[0-9a-f]{12}$/.test(p.ws) ? { ws: p.ws } : {}) }; else delete out.project; } for (const k of NUM_OR_NULL_KEYS) { diff --git a/core/project.js b/core/project.js index f1b167c..a9f078e 100644 --- a/core/project.js +++ b/core/project.js @@ -5,9 +5,12 @@ const crypto = require("node:crypto"); /** * @typedef {object} ProjectRef - * @property {string} id 12 hex chars, stable across a repo's checkouts; never a path - * @property {string} name display name (the main repo's folder name for a worktree) + * @property {string} id group id, 12 hex chars: the git remote (host + path) when one is + * found, else the main git dir, so every clone and worktree of a remote + * shares it; never a path + * @property {string} name `org/repo` from the remote, else the main repo's folder name * @property {string} root the checkout the run came from (absolute path; masked at serve time) + * @property {string} ws workspace id, 12 hex chars of the checkout's real path */ const MEMO_MAX = 256; @@ -20,6 +23,58 @@ const projectIdOf = (basis) => crypto.createHash("sha256").update(basis).digest( /** @param {string} p @returns {string} */ const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } }; +/** + * `{host, path}` of a remote URL, or null for a local or unparseable one. Credentials and a + * `.git` suffix are dropped; the raw URL is never kept (an https URL can carry a token). + * @param {string} url + * @returns {({host: string, path: string}|null)} + */ +function parseRemote(url) { + const u = String(url || "").trim(); + let m = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(u); + if (m && /^file:/i.test(u)) return null; + if (!m) m = /^(?:[^@/:]+@)?([^/:]+):(?!\/)(.+)$/.exec(u); // scp form: git@host:org/repo + if (!m) return null; + const repoPath = m[2].replace(/\/+$/, "").replace(/\.git$/i, "").replace(/^\/+/, ""); + if (!repoPath || !repoPath.includes("/")) return null; + return { host: m[1].toLowerCase(), path: repoPath }; +} + +/** + * The `origin` remote of a git dir's config, else its first remote; null when none parses. + * @param {string} gitDir + * @returns {({host: string, path: string}|null)} + */ +function remoteOf(gitDir) { + let text; + try { text = fs.readFileSync(path.join(gitDir, "config"), "utf8"); } catch { return null; } + /** @type {Map} */ + const urls = new Map(); + /** @type {(string|null)} */ + let section = null; + for (const line of text.split(/\r?\n/)) { + const head = /^\s*\[\s*remote\s+"([^"]+)"\s*\]/.exec(line); + if (head) { section = head[1]; continue; } + if (/^\s*\[/.test(line)) { section = null; continue; } + const kv = section !== null && /^\s*url\s*=\s*(.+?)\s*$/i.exec(line); + if (kv && section !== null && !urls.has(section)) urls.set(section, kv[1]); + } + const url = urls.get("origin") ?? urls.values().next().value; + return url ? parseRemote(url) : null; +} + +/** + * Group a checkout by its remote when it has one. `gitDir` is the main git dir. + * @param {string} gitDir @param {string} fallbackName @param {string} root + * @returns {ProjectRef} + */ +function gitRef(gitDir, fallbackName, root) { + const r = remoteOf(gitDir); + const ws = projectIdOf(real(root)); + if (r) return { id: projectIdOf(`remote\0${r.host}/${r.path.toLowerCase()}`), name: r.path, root, ws }; + return { id: projectIdOf(real(gitDir)), name: fallbackName, root, ws }; +} + /** * Read a `.git` FILE (`gitdir: X`). Returns the main git dir that identifies the * repo, and whether the checkout is a worktree, or null when the file is not a @@ -56,22 +111,22 @@ function resolveProject(dir) { return null; } /** @type {ProjectRef} */ - let ref = { id: projectIdOf(real(start)), name: path.basename(start) || start, root: start }; + let ref = { id: projectIdOf(real(start)), name: path.basename(start) || start, root: start, ws: projectIdOf(real(start)) }; try { for (let cur = start; ; cur = path.dirname(cur)) { const dotGit = path.join(cur, ".git"); let st = null; try { st = fs.statSync(dotGit); } catch { /* keep walking */ } if (st && st.isDirectory()) { - ref = { id: projectIdOf(real(dotGit)), name: path.basename(cur) || cur, root: cur }; + ref = gitRef(dotGit, path.basename(cur) || cur, cur); break; } if (st && st.isFile()) { const g = readGitFile(dotGit); - if (!g) ref = { id: projectIdOf(real(cur)), name: path.basename(cur) || cur, root: cur }; + if (!g) ref = { id: projectIdOf(real(cur)), name: path.basename(cur) || cur, root: cur, ws: projectIdOf(real(cur)) }; else { const mainRoot = path.dirname(g.mainGitDir); - ref = { id: projectIdOf(real(g.mainGitDir)), name: path.basename(g.worktree ? mainRoot : cur) || cur, root: cur }; + ref = gitRef(g.mainGitDir, path.basename(g.worktree ? mainRoot : cur) || cur, cur); } break; } @@ -85,4 +140,24 @@ function resolveProject(dir) { return ref; } -module.exports = { resolveProject, projectIdOf }; +const HEX12 = /^[0-9a-f]{12}$/; + +/** + * A journal `project` in today's shape, or null. A run recorded before `ws` existed is + * re-resolved from its root when that dir still exists (so it joins its remote group); + * otherwise it keeps its old group and gets a ws from the root path. + * @param {unknown} p + * @returns {(ProjectRef|null)} + */ +function normalizeProject(p) { + const v = /** @type {any} */ (p); + if (!v || typeof v !== "object" || typeof v.id !== "string") return null; + const root = String(v.root || ""); + const base = { id: v.id, name: String(v.name || v.id), root }; + if (typeof v.ws === "string" && HEX12.test(v.ws)) return { ...base, ws: v.ws }; + const now = root && path.isAbsolute(root) ? resolveProject(root) : null; + if (now && now.root === root) return now; + return { ...base, ws: projectIdOf(root || v.id) }; +} + +module.exports = { resolveProject, normalizeProject, parseRemote, projectIdOf }; diff --git a/docs/dev/design-decisions.md b/docs/dev/design-decisions.md index a1b1b96..dfbe9c5 100644 --- a/docs/dev/design-decisions.md +++ b/docs/dev/design-decisions.md @@ -43,4 +43,4 @@ Why the code is the way it is, with the incidents and reviews behind each choice 16. **Ground the date in code, leave the lookup to the host** - external PR #204 proposed a standalone "mandatory temporal grounding" skill (run `date -u`, query AWS/Terraform MCP tools, search the web) pasted into AGENTS.md and the host rules. The problem is real - a delegate trained months ago calls a newer model or tool "hallucinated" and files a false critical issue that blocks a consensus round - but prose aimed at the delegates cannot fix it: Grok and OpenRouter run with `tools: []`, the vendor tool names exist only on some hosts, and persona text is paid on every call times panel size times rounds. `/ask-all` (5/5) split it by who can act. The date is a FACT only code can supply, so `core/grounding.js groundingNote()` stamps it, with a one-sentence no-denial rule (unrecognized = `[unverified]`, never "non-existent"), into every prompt builder (Codex `runOnce`, Gemini `buildAgyArgs`, Grok and OpenRouter `buildInitialTurns`) - the builders, not `orchestrate.js`, because they are the one place every path meets, standalone `/ask-*` bridges included. The rule ships WITH the date because a date alone still lets a model deny a name it does not know. Retrieval is the host's job: `docs/tool-guide.md` "Time-sensitive questions", every `/ask-*` command, `/consensus`, and the CONTEXT section of `rules/delegation-format.md` tell the orchestrator to verify and inline facts with an as-of date and source. Personas are unchanged, no skill, no config key. Details in [TECHNICAL.md ยง Date grounding](../../TECHNICAL.md#date-grounding). -17. **The dashboard tails a journal the MCP processes write** - the local dashboard (`deliberation-mcp dashboard`, `/deliberation:dashboard`) draws each run as a state graph live and after the fact. Every host session runs its own stdio server, so the MCP processes append to a shared journal (`core/journal.js`, one `.jsonl` per run under `/deliberation/runs/`, `DELIBERATION_RUNS`) and the dashboard is a separate process that tails it. Rejected: a localhost socket (events before the dashboard started are lost) and hosting it inside an MCP process (sessions contend for one port, and the view dies with its session). Events come from the one chokepoint every provider call passes (`callProvider`: `call_start`/`call_end`) plus the tool handlers (`run_start`/`state`/`arbiter`/`run_end`); `consensus-step` uses its `sessionId` as the `runId`, and `/ask-all` joins its parallel `ask-one` calls through the `runId` that `panel` returns (only ids this process's `panel` opened). Privacy is two switches: `dashboard.capture` (`metadata` default; `content` adds prompts/responses, `scrubSecrets` then `capText` at write, per-kind key whitelist) and `dashboard.showPII` (PII redacted by `core/redact.js` at SERVE time, so flipping it needs no rewrite). Journaling never throws into a delegation, the debug log's content ban is unchanged, and the dashboard is read-only (GET/HEAD). Threat model: loopback bind, Host/Origin pinned to `127.0.0.1:`/`localhost:` against DNS rebinding, a per-start 32-byte token in the printed URL swapped for an `HttpOnly; SameSite=Strict` cookie (browsers send it to every 127.0.0.1 port: Host/Origin checks and `nosniff` stop a web page from reading data cross-port, but a local service that receives the cookie can replay the token server-side, e.g. curl with `Host: 127.0.0.1:` plus the cookie or `?t=`, and read all dashboard data. Accepted because any local listener is already inside the same-machine trust boundary - the design scope is a single-user laptop; `dlb_dash` is only issued after the user opens the tokenized URL, and a restart rotates the token. On shared multi-user machines, do not enable the dashboard or `capture: "content"`), the token kept off argv via a 0600 redirect file, and CSP + `textContent`-only rendering. Run status is derived at read time (`deriveStatus` in `server/dashboard/runs.js`), never cached, because a crashed writer never touches its file again: a `run_end` wins; a `fanout` is done when every listed provider's latest call ended, or after 60 s of quiet with the uncalled providers as `undispatched`, and `abandoned` (`never-dispatched`) with no call after 20 minutes; a `consensus-step` idle past the loop store TTL is `abandoned` (`expired`); otherwise the run is live while its writer pid is (pid reuse checked by start time, Linux only). Packaging: `prepack` copies `server/dashboard/ui` to `dist/dashboard-ui`; the circular require (dashboard -> `server/mcp/index.js`) makes esbuild wrap the entry, so the main guard compares `require.main.filename` to `__filename` rather than `require.main === module` (`test/mcp-bundle.test.js`). Details in [TECHNICAL.md - Dashboard](../../TECHNICAL.md#dashboard). **Analyzer + project + restart.** Every `run_start` carries `project` (`core/project.js`: git repo of the tool's `cwd`, worktrees grouped under their main repo by a hashed id, stat-only, never a spawn) and every `call_start` the request size as sent (`promptChars`/`fileBytes`, after orientation and consensus prompt building) plus the ceiling the attempt actually got (`grantedMs`) and where it came from, attributed by ORIGIN (`own`/`shared`/`outer`/`host`, `sharedBy` = the slowest SELECTED peer that set a fan-out deadline, dispatched or not): attributing by "which number is smallest" mislabels an ordinary call once setup time makes its own generated deadline the minimum. The Analyzer (`server/dashboard/analyzer.js`, `/api/analyzer`) reads only that metadata. Model overlap is consensus-only, because `call_end` carries no verdict and ask-all answers are never parsed; a drop candidate also needs arbiter decisions (`arbiter.decisions[]`, consensus-step only), so "agrees with everyone" alone never flags a model. Timeout advice treats timeouts as censored data (a success-only p95 understates large requests) and always suggests more than the calls already got. `dashboard --stop` (`server/dashboard/stop.js`) signals a pid only when argv position and start time prove it is the dashboard: the token probe proves a listener, not a pid. `dashboard-restart.sh` and `reload-mcp.sh` both go through it. The Stats tab still reads the debug log, so it is the one view the project filter does not reach. +17. **The dashboard tails a journal the MCP processes write** - the local dashboard (`deliberation-mcp dashboard`, `/deliberation:dashboard`) draws each run as a state graph live and after the fact. Every host session runs its own stdio server, so the MCP processes append to a shared journal (`core/journal.js`, one `.jsonl` per run under `/deliberation/runs/`, `DELIBERATION_RUNS`) and the dashboard is a separate process that tails it. Rejected: a localhost socket (events before the dashboard started are lost) and hosting it inside an MCP process (sessions contend for one port, and the view dies with its session). Events come from the one chokepoint every provider call passes (`callProvider`: `call_start`/`call_end`) plus the tool handlers (`run_start`/`state`/`arbiter`/`run_end`); `consensus-step` uses its `sessionId` as the `runId`, and `/ask-all` joins its parallel `ask-one` calls through the `runId` that `panel` returns (only ids this process's `panel` opened). Privacy is two switches: `dashboard.capture` (`metadata` default; `content` adds prompts/responses, `scrubSecrets` then `capText` at write, per-kind key whitelist) and `dashboard.showPII` (PII redacted by `core/redact.js` at SERVE time, so flipping it needs no rewrite). Journaling never throws into a delegation, the debug log's content ban is unchanged, and the dashboard is read-only (GET/HEAD). Threat model: loopback bind, Host/Origin pinned to `127.0.0.1:`/`localhost:` against DNS rebinding, a per-start 32-byte token in the printed URL swapped for an `HttpOnly; SameSite=Strict` cookie (browsers send it to every 127.0.0.1 port: Host/Origin checks and `nosniff` stop a web page from reading data cross-port, but a local service that receives the cookie can replay the token server-side, e.g. curl with `Host: 127.0.0.1:` plus the cookie or `?t=`, and read all dashboard data. Accepted because any local listener is already inside the same-machine trust boundary - the design scope is a single-user laptop; `dlb_dash` is only issued after the user opens the tokenized URL, and a restart rotates the token. On shared multi-user machines, do not enable the dashboard or `capture: "content"`), the token kept off argv via a 0600 redirect file, and CSP + `textContent`-only rendering. Run status is derived at read time (`deriveStatus` in `server/dashboard/runs.js`), never cached, because a crashed writer never touches its file again: a `run_end` wins; a `fanout` is done when every listed provider's latest call ended, or after 60 s of quiet with the uncalled providers as `undispatched`, and `abandoned` (`never-dispatched`) with no call after 20 minutes; a `consensus-step` idle past the loop store TTL is `abandoned` (`expired`); otherwise the run is live while its writer pid is (pid reuse checked by start time, Linux only). Packaging: `prepack` copies `server/dashboard/ui` to `dist/dashboard-ui`; the circular require (dashboard -> `server/mcp/index.js`) makes esbuild wrap the entry, so the main guard compares `require.main.filename` to `__filename` rather than `require.main === module` (`test/mcp-bundle.test.js`). Details in [TECHNICAL.md - Dashboard](../../TECHNICAL.md#dashboard). **Analyzer + project + restart.** Every `run_start` carries `project` (`core/project.js`: grouped by the git remote's `org/repo` with a hashed host+path id, falling back to the main git dir when there is no remote, plus the checkout as a hashed workspace `ws`; stat and read only, never a spawn. The remote is the key because the same repo is often checked out several times (T3 or other tool worktrees, review clones under `/tmp`), and debugging is per repo first; the raw URL is never stored since an https remote can carry a token. Runs from before `ws` are regrouped at read time when their root still exists, never rewritten) and every `call_start` the request size as sent (`promptChars`/`fileBytes`, after orientation and consensus prompt building) plus the ceiling the attempt actually got (`grantedMs`) and where it came from, attributed by ORIGIN (`own`/`shared`/`outer`/`host`, `sharedBy` = the slowest SELECTED peer that set a fan-out deadline, dispatched or not): attributing by "which number is smallest" mislabels an ordinary call once setup time makes its own generated deadline the minimum. The Analyzer (`server/dashboard/analyzer.js`, `/api/analyzer`) reads only that metadata. Model overlap is consensus-only, because `call_end` carries no verdict and ask-all answers are never parsed; a drop candidate also needs arbiter decisions (`arbiter.decisions[]`, consensus-step only), so "agrees with everyone" alone never flags a model. Timeout advice treats timeouts as censored data (a success-only p95 understates large requests) and always suggests more than the calls already got. `dashboard --stop` (`server/dashboard/stop.js`) signals a pid only when argv position and start time prove it is the dashboard: the token probe proves a listener, not a pid. `dashboard-restart.sh` and `reload-mcp.sh` both go through it. The Stats tab still reads the debug log, so it is the one view the project filter does not reach. diff --git a/docs/tool-guide.md b/docs/tool-guide.md index 93a8ed6..c91d9dc 100644 --- a/docs/tool-guide.md +++ b/docs/tool-guide.md @@ -158,7 +158,8 @@ These apply to every MCP host, not just Claude Code: open the printed `http://127.0.0.1:/?t=` URL. It is a read-only browser view of live and past runs as state graphs, with config, provider health, stats, and an Analyzer (drop candidates, per-project health, request size vs timeouts; every run records its - calling project from the tool's `cwd`). `deliberation-mcp dashboard --stop` stops it. Loopback + calling project from the tool's `cwd`, grouped by git remote `org/repo` with each checkout + as a workspace). `deliberation-mcp dashboard --stop` stops it. Loopback only, token-protected; `dashboard.capture` is `metadata` (default) or `content` (prompts and responses, secret-scrubbed), and PII is redacted in the browser unless `dashboard.showPII`. Needs a browser on the same machine. OFF by default. diff --git a/plugins/deliberation/skills/deliberation/SKILL.md b/plugins/deliberation/skills/deliberation/SKILL.md index 44c095d..476715a 100644 --- a/plugins/deliberation/skills/deliberation/SKILL.md +++ b/plugins/deliberation/skills/deliberation/SKILL.md @@ -164,7 +164,8 @@ These apply to every MCP host, not just Claude Code: open the printed `http://127.0.0.1:/?t=` URL. It is a read-only browser view of live and past runs as state graphs, with config, provider health, stats, and an Analyzer (drop candidates, per-project health, request size vs timeouts; every run records its - calling project from the tool's `cwd`). `deliberation-mcp dashboard --stop` stops it. Loopback + calling project from the tool's `cwd`, grouped by git remote `org/repo` with each checkout + as a workspace). `deliberation-mcp dashboard --stop` stops it. Loopback only, token-protected; `dashboard.capture` is `metadata` (default) or `content` (prompts and responses, secret-scrubbed), and PII is redacted in the browser unless `dashboard.showPII`. Needs a browser on the same machine. OFF by default. diff --git a/server/dashboard/analyzer.js b/server/dashboard/analyzer.js index 3c34078..6d19363 100644 --- a/server/dashboard/analyzer.js +++ b/server/dashboard/analyzer.js @@ -6,13 +6,17 @@ * Pure: takes `{summary, events}` run details (server/dashboard/runs.js `get`) and returns * plain JSON. Reads metadata fields only, never prompt or response text. Never throws on * junk input; a malformed event is skipped. Three views: - * - projects: per calling project, runs, call timeouts, latency, error kinds; + * - projects: per repo group (git remote) and per workspace under it: runs, call + * timeouts, latency, error kinds. A run recorded before `ws` existed is + * re-resolved from its root (core/project.js normalizeProject: stat only); * - models: per consensus voice, how often it agrees instead of adding findings, and * whether it is a drop candidate (all thresholds in C, shared with the tests); * - size: request size vs latency and timeouts per provider + model, with timeout * advice that treats timeouts as censored (their real latency is unknown). */ +const { normalizeProject } = require("../../core/project.js"); + /** Thresholds. Exported so tests and the UI's method notes use the same numbers. */ const C = Object.freeze({ MIN_ROUNDS: 10, @@ -42,7 +46,7 @@ const BUCKETS = [ ]; /** Ceilings this config controls: only these drive advice and model speed/error. */ const ADVISABLE = new Set(["own", "shared"]); -const UNKNOWN_PROJECT = { id: "unknown", name: "(unknown)", root: "" }; +const UNKNOWN_PROJECT = { id: "unknown", name: "(unknown)", root: "", ws: "unknown" }; /** @param {string} p */ const timeoutKey = (p) => (p.startsWith("openrouter:") ? `models.${p.slice(11)}.timeout` : `providers.${p}.timeout`); @@ -98,37 +102,56 @@ function calls(events) { function projectOf(run) { const p = run.summary && run.summary.project; const start = run.events.find((/** @type {any} */ e) => e && e.kind === "run_start"); - const ref = p && typeof p.id === "string" ? p : start && start.project; - return ref && typeof ref.id === "string" ? { id: ref.id, name: String(ref.name || ref.id), root: String(ref.root || "") } : UNKNOWN_PROJECT; + return normalizeProject(p && typeof p.id === "string" ? p : start && start.project) || UNKNOWN_PROJECT; } -/** @param {any[]} runs */ +/** One metrics bucket (a repo group or one of its workspaces). */ +const emptyRow = () => ({ runs: 0, status: /** @type {Record} */ ({}), calls: 0, timeouts: 0, errors: 0, ms: /** @type {number[]} */ ([]), kinds: new Map() }); + +/** @param {any} row @param {any} run */ +function addRun(row, run) { + row.runs++; + const st = String((run.summary && run.summary.status) || "unknown"); + row.status[st] = (row.status[st] || 0) + 1; + for (const { end } of calls(run.events)) { + if (end.cached) continue; + row.calls++; + if (end.isError) { + row.errors++; + const k = String(end.errorKind || "unknown"); + if (k === "timeout") row.timeouts++; + row.kinds.set(k, (row.kinds.get(k) || 0) + 1); + } else if (num(end.ms)) row.ms.push(end.ms); + } +} + +/** @param {any} r */ +const metrics = (r) => ({ + runs: r.runs, status: r.status, calls: r.calls, + timeouts: r.timeouts, timeoutRate: ratio(r.timeouts, r.calls), errorRate: ratio(r.errors, r.calls), + p50: median(r.ms), p95: quantile(r.ms, 0.95), + topErrors: [...r.kinds.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([kind, n]) => ({ kind, n })), +}); + +/** + * Per repo group, with the same metrics per workspace (checkout) under it. + * @param {any[]} runs + */ function projectsView(runs) { /** @type {Map} */ const by = new Map(); for (const run of runs) { const p = projectOf(run); - const row = by.get(p.id) || { ...p, runs: 0, status: {}, calls: 0, timeouts: 0, errors: 0, ms: [], kinds: new Map() }; - by.set(p.id, row); - row.runs++; - const st = String((run.summary && run.summary.status) || "unknown"); - row.status[st] = (row.status[st] || 0) + 1; - for (const { end } of calls(run.events)) { - if (end.cached) continue; - row.calls++; - if (end.isError) { - row.errors++; - const k = String(end.errorKind || "unknown"); - if (k === "timeout") row.timeouts++; - row.kinds.set(k, (row.kinds.get(k) || 0) + 1); - } else if (num(end.ms)) row.ms.push(end.ms); - } + const g = by.get(p.id) || { id: p.id, name: p.name, root: p.root, row: emptyRow(), ws: new Map() }; + by.set(p.id, g); + addRun(g.row, run); + const w = g.ws.get(p.ws) || { ws: p.ws, root: p.root, row: emptyRow() }; + g.ws.set(p.ws, w); + addRun(w.row, run); } - return [...by.values()].map((r) => ({ - id: r.id, name: r.name, root: r.root, runs: r.runs, status: r.status, calls: r.calls, - timeouts: r.timeouts, timeoutRate: ratio(r.timeouts, r.calls), errorRate: ratio(r.errors, r.calls), - p50: median(r.ms), p95: quantile(r.ms, 0.95), - topErrors: [...r.kinds.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([kind, n]) => ({ kind, n })), + return [...by.values()].map((g) => ({ + id: g.id, name: g.name, root: g.root, ...metrics(g.row), + workspaces: [...g.ws.values()].map((w) => ({ ws: w.ws, root: w.root, ...metrics(w.row) })).sort((a, b) => b.runs - a.runs || a.root.localeCompare(b.root)), })).sort((a, b) => b.runs - a.runs); } diff --git a/server/dashboard/runs.js b/server/dashboard/runs.js index 4dfcbf3..338a0de 100644 --- a/server/dashboard/runs.js +++ b/server/dashboard/runs.js @@ -20,6 +20,7 @@ const os = require("node:os"); const { isSafeId } = require("../../core/journal.js"); const { readSession, listSessions } = require("../../core/sessions.js"); const { redactString } = require("../../core/redact.js"); +const { normalizeProject } = require("../../core/project.js"); // The loop store's TTL: past it a consensus-step loop's state is gone, so the host cannot resume it. const { DEFAULT_TTL_MS: STEP_TTL_MS } = require("../../core/loop-store.js"); @@ -49,7 +50,7 @@ const { DEFAULT_TTL_MS: STEP_TTL_MS } = require("../../core/loop-store.js"); * @property {boolean} legacy * @property {(string|null)} stopReason * @property {string[]} [undispatched] a quiet fan-out's listed providers that were never called - * @property {({id:string, name:string, root:string}|null)} [project] where the run was called from (run_start), null when unknown + * @property {(import("../../core/project.js").ProjectRef|null)} [project] where the run was called from (run_start), null when unknown */ /** @typedef {(pid: number, procStartedAt: number) => boolean} IsAliveFn */ @@ -340,7 +341,7 @@ function foldRun(events) { let maxRound = 0; let tokens = 0; let tokenCoverage=0,reused=0,attempts=0,retries=0; - /** @type {({id:string, name:string, root:string}|null)} */ let project=null; + /** @type {(import("../../core/project.js").ProjectRef|null)} */ let project=null; /** @type {any} */ let provenance=null; /** @type {(number|null)} */ let minAt = null; @@ -367,7 +368,7 @@ function foldRun(events) { if (typeof ev.workflow === "string") workflow = ev.workflow; if (at !== null) runStartAt = at; if (Array.isArray(ev.providers)) providersFromStart = ev.providers.filter((/** @type {any} */ p) => typeof p === "string"); - if (ev.project && typeof ev.project === "object" && typeof ev.project.id === "string") project = { id: ev.project.id, name: String(ev.project.name || ev.project.id), root: String(ev.project.root || "") }; + project = normalizeProject(ev.project) || project; break; case "run_end": runEnd = ev; @@ -480,7 +481,8 @@ function legacySummary(id, record) { * @property {string} [provider] * @property {string} [status] * @property {(number|string)} [since] epoch ms, or an ISO/Date.parse-able string; keeps runs starting at/after it - * @property {string} [project] a project id; `unknown` matches runs that recorded none + * @property {string} [project] a project (repo group) id; `unknown` matches runs that recorded none + * @property {string} [ws] a workspace id; wins over `project` when both are set */ /** @@ -609,7 +611,8 @@ function createRunIndex(opts) { function matchesFilter(s, entry, filter) { if (filter.tool && s.tool !== filter.tool) return false; if (filter.status && s.status !== filter.status) return false; - if (filter.project && (s.project ? s.project.id : "unknown") !== filter.project) return false; + if (filter.ws) { if (!s.project || s.project.ws !== filter.ws) return false; } + else if (filter.project && (s.project ? s.project.id : "unknown") !== filter.project) return false; if (filter.provider && s.providers.indexOf(filter.provider) === -1) return false; if (filter.since !== undefined && filter.since !== null) { const sinceMs = typeof filter.since === "number" ? filter.since : Date.parse(String(filter.since)); diff --git a/server/dashboard/server.js b/server/dashboard/server.js index 8df1435..8e24d66 100644 --- a/server/dashboard/server.js +++ b/server/dashboard/server.js @@ -26,6 +26,8 @@ const ANALYZER_MAX_DAYS = 365; const ANALYZER_MAX_RUNS = 2000; // core/project.js ids are 12 hex chars; `unknown` selects runs that recorded no project. const PROJECT_ID_RE = /^(?:[0-9a-f]{12}|unknown)$/; +// A workspace id (core/project.js `ws`): the checkout a run came from, under its project. +const WS_ID_RE = /^[0-9a-f]{12}$/; const COOKIE = "dlb_dash"; const KEEPALIVE_MS = 15000; @@ -337,6 +339,7 @@ function createDashboardServer(opts) { provider: q.get("provider") || undefined, status: q.get("status") || undefined, project: q.get("project") || undefined, + ws: q.get("ws") || undefined, since: sinceRaw && /^\d+$/.test(sinceRaw) ? Number(sinceRaw) : sinceRaw || undefined, redacted: !showPII(), metadataOnly: !capturesContent(), @@ -361,12 +364,14 @@ function createDashboardServer(opts) { if (p === "/api/analyzer") { const q = url.searchParams; const project = q.get("project") || undefined; + const ws = q.get("ws") || undefined; const days = q.has("days") ? Number(q.get("days")) : ANALYZER_DEFAULT_DAYS; if (project !== undefined && !PROJECT_ID_RE.test(project)) return sendJson(req, res, 400, { error: "invalid project id" }); + if (ws !== undefined && !WS_ID_RE.test(ws)) return sendJson(req, res, 400, { error: "invalid workspace id" }); if (!Number.isInteger(days) || days < 1 || days > ANALYZER_MAX_DAYS) return sendJson(req, res, 400, { error: `days must be an integer from 1 to ${ANALYZER_MAX_DAYS}` }); - const summaries = index.list({ since: Date.now() - days * 86400000, project }).filter((r) => !r.legacy); + const summaries = index.list({ since: Date.now() - days * 86400000, project, ws }).filter((r) => !r.legacy); const details = summaries.slice(0, ANALYZER_MAX_RUNS).map((r) => index.get(r.runId)).filter(Boolean); - const window = { days, project: project || null, runsInWindow: summaries.length, analyzed: details.length, truncated: summaries.length > ANALYZER_MAX_RUNS || !!index.truncated?.() }; + const window = { days, project: project || null, ws: ws || null, runsInWindow: summaries.length, analyzed: details.length, truncated: summaries.length > ANALYZER_MAX_RUNS || !!index.truncated?.() }; return sendJson(req, res, 200, outward({ ...analyze(details), window })); } if (p === "/api/events") return sendEvents(req, res); diff --git a/server/dashboard/ui/app.js b/server/dashboard/ui/app.js index 1c91471..b9f4be3 100644 --- a/server/dashboard/ui/app.js +++ b/server/dashboard/ui/app.js @@ -24,7 +24,7 @@ export function emptyRun(runId) { } /** A run_start/summary project, or null when absent or the wrong shape. */ -const projectOf = (p) => (p && typeof p === "object" && typeof p.id === "string" ? { id: p.id, name: String(p.name || p.id), root: String(p.root || "") } : null); +const projectOf = (p) => (p && typeof p === "object" && typeof p.id === "string" ? { id: p.id, name: String(p.name || p.id), root: String(p.root || ""), ...(typeof p.ws === "string" ? { ws: p.ws } : {}) } : null); function tokensOf(usage) { if (!usage || typeof usage !== "object") return 0; diff --git a/server/dashboard/ui/style.css b/server/dashboard/ui/style.css index 25a7f63..b351bb1 100644 --- a/server/dashboard/ui/style.css +++ b/server/dashboard/ui/style.css @@ -440,6 +440,13 @@ input[type="search"] { width: 230px; } .index-row > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .c-prov, .c-id, .c-time { color: var(--fg-2); } .c-proj { font-weight: 500; } +.c-ws { font-weight: 400; color: var(--fg-2); } +.linkish { padding: 0; border: 0; background: none; font: inherit; color: inherit; text-align: left; cursor: pointer; } +.linkish:hover, .linkish:focus-visible { text-decoration: underline; } +.tree-toggle { min-width: 2.2em; margin-right: 6px; padding: 0 4px; border: 1px solid var(--rule-strong); border-radius: 2px; background: none; font: 500 10.5px/1.4 var(--mono); color: var(--fg-2); cursor: pointer; } +.tree-toggle::before { content: "\25B8 "; } +.tree-toggle[aria-expanded="true"]::before { content: "\25BE "; } +.ws-row .ws-name { padding-left: 28px; color: var(--fg-2); } .kind-drop { color: var(--fault-text); border-color: var(--fault-text); } .grid-table tr.is-flagged td { background: color-mix(in oklch, var(--fault-text) 6%, transparent); } .size-group + .size-group { margin-top: 18px; } diff --git a/server/dashboard/ui/views/analyzer.js b/server/dashboard/ui/views/analyzer.js index e6cf226..4dc4ba8 100644 --- a/server/dashboard/ui/views/analyzer.js +++ b/server/dashboard/ui/views/analyzer.js @@ -4,7 +4,7 @@ import { api, store } from "../api.js"; import { h, put, fmtMs, fmtInt, num, providerLabel } from "../dom.js"; -import { projectOptions, projectSelect } from "./runs.js"; +import { projectOptions, projectSelect, projectFilter } from "./runs.js"; const pct = (v) => (num(v) === null ? "-" : `${Math.round(v * 100)}%`); const DAYS = [[7, "Last 7 days"], [30, "Last 30 days"], [90, "Last 90 days"], [365, "Last year"]]; @@ -43,18 +43,31 @@ function modelsPanel(models, C) { : h("p", { class: "empty" }, "No consensus rounds in this window. Run /consensus with dashboard.enabled on.")); } -function projectsPanel(projects) { - const rows = projects.map((p) => h("tr", {}, - h("td", { class: "strong", title: p.root || "no project recorded" }, p.name), +function projectsPanel(projects, pick) { + const cells = (p) => [ h("td", { class: "num" }, fmtInt(p.runs)), h("td", { class: "num" }, fmtInt(p.calls)), h("td", { class: `num${p.timeouts ? " has-errors" : ""}` }, p.timeouts ? `${p.timeouts} (${pct(p.timeoutRate)})` : "0"), h("td", { class: `num${num(p.errorRate) ? " has-errors" : ""}` }, pct(p.errorRate)), h("td", { class: "num" }, fmtMs(p.p50)), h("td", { class: "num" }, fmtMs(p.p95)), - h("td", {}, (p.topErrors || []).map((e) => `${e.kind} ${e.n}`).join(", ") || "-"))); + h("td", {}, (p.topErrors || []).map((e) => `${e.kind} ${e.n}`).join(", ") || "-")]; + const link = (label, value, title) => h("button", { type: "button", class: "linkish", title, onclick: () => pick(value) }, label); + const rows = projects.flatMap((p) => { + const ws = Array.isArray(p.workspaces) ? p.workspaces : []; + const one = ws.length === 1 ? ws[0].root : ""; + const kids = ws.length > 1 ? ws.map((w) => h("tr", { class: "ws-row", hidden: true }, + h("td", { class: "ws-name" }, link(w.root || w.ws, `w:${w.ws}`, `Only runs from ${w.root || w.ws}`)), ...cells(w))) : []; + const toggle = kids.length ? h("button", { type: "button", class: "tree-toggle", "aria-expanded": "false", "aria-label": `Show ${kids.length} workspaces of ${p.name}`, onclick: (e) => { + const open = e.currentTarget.getAttribute("aria-expanded") !== "true"; + e.currentTarget.setAttribute("aria-expanded", String(open)); + for (const k of kids) k.hidden = !open; + } }, `${kids.length}`) : null; + return [h("tr", {}, + h("td", { class: "strong" }, toggle, link(one ? `${p.name} (${one})` : p.name, `p:${p.id}`, p.id === "unknown" ? "no project recorded" : `All workspaces of ${p.name}`)), ...cells(p)), ...kids]; + }); return h("section", { class: "panel" }, h("h2", {}, "Projects"), - h("p", { class: "hint" }, "Where each run was called from: the git repo of the tool's cwd (worktrees count as their main repo). Hover a name for its path. Cached answers are excluded from latency."), + h("p", { class: "hint" }, "Where each run was called from, grouped by git remote (org/repo), then by workspace dir: every clone and worktree of a repo lands under one row. Expand a row for its workspaces; click a name to filter to it. Cached answers are excluded from latency."), rows.length ? table(["project", "runs", "calls", "timeouts", "errors", "p50", "p95", "top errors"], rows, (i) => i >= 1 && i <= 6) : h("p", { class: "empty" }, "No runs in this window.")); } @@ -105,13 +118,15 @@ export function create(ctx) { function load() { const n = ++gen; - api.analyzer({ project, days }).then((r) => { if (!disposed && n === gen) draw(r); }, (e) => { if (!disposed && n === gen) draw({ error: e.message }); }); + api.analyzer({ ...projectFilter(project), days }).then((r) => { if (!disposed && n === gen) draw(r); }, (e) => { if (!disposed && n === gen) draw({ error: e.message }); }); } function draw(r) { last = r; - const known = r.projects ? r.projects.map((p) => ({ project: p.id === "unknown" ? null : { id: p.id, name: p.name, root: p.root } })) : []; - const sel = projectSelect(projectOptions([...ctx.runList(), ...known], project), project, (e) => { project = e.target.value; store.set("project", project); load(); }); + const known = (r.projects || []).flatMap((p) => p.id === "unknown" ? [{ project: null }] + : (p.workspaces || []).map((w) => ({ project: { id: p.id, name: p.name, root: w.root, ws: w.ws } }))); + const pick = (v) => { project = v; store.set("project", project); load(); }; + const sel = projectSelect(projectOptions([...ctx.runList(), ...known], project), project, (e) => pick(e.target.value)); const win = h("select", { "aria-label": "Time window", onchange: (e) => { days = Number(e.target.value); store.set("analyzerDays", days); load(); } }, DAYS.map(([v, t]) => h("option", { value: v, selected: v === days }, t))); const w = r.window || {}; const controls = h("section", { class: "panel stats-filters" }, @@ -119,7 +134,7 @@ export function create(ctx) { r.error ? null : h("span", { class: "muted" }, `${fmtInt(w.analyzed)} runs analyzed${w.truncated ? ` (window capped; ${fmtInt(w.runsInWindow)} in range)` : ""}`)); if (r.error) return put(el, controls, h("section", { class: "panel" }, h("h2", {}, "Analyzer"), h("p", { class: "error-note" }, `The analyzer is unavailable: ${r.error}`))); const C = r.constants || {}; - put(el, controls, modelsPanel(r.models || [], C), projectsPanel(r.projects || []), sizePanel(r)); + put(el, controls, modelsPanel(r.models || [], C), projectsPanel(r.projects || [], pick), sizePanel(r)); } load(); diff --git a/server/dashboard/ui/views/runs.js b/server/dashboard/ui/views/runs.js index a05afad..3d53978 100644 --- a/server/dashboard/ui/views/runs.js +++ b/server/dashboard/ui/views/runs.js @@ -13,24 +13,53 @@ function field(label, control) { } /** - * Project choices from the runs in view: value = project id, label = name, plus the short - * id when two projects share a name. The selected id stays listed even if no run shows it. + * Project choices from the runs in view, as a tree: each repo group (value `p:`, all its + * workspaces) then, when it has more than one, each workspace (value `w:`). A group with + * one workspace shows it inline: `org/repo (/path)`. Same-named groups get a short id. The + * selected value stays listed even if no run shows it. * @param {any[]} runs @param {string} selected - * @returns {{id: string, label: string}[]} + * @returns {{id: string, label: string, depth: number}[]} */ export function projectOptions(runs, selected) { + /** @type {Map}>} */ const by = new Map(); - for (const r of runs) if (r.project && typeof r.project.id === "string") by.set(r.project.id, r.project.name); - const names = [...by.values()]; - const out = [...by.entries()].map(([id, name]) => ({ id, label: names.filter((n) => n === name).length > 1 ? `${name} (${id.slice(0, 6)})` : name })); - if (runs.some((r) => !r.project)) out.push({ id: "unknown", label: "(unknown)" }); - if (selected && !out.some((o) => o.id === selected)) out.push({ id: selected, label: selected === "unknown" ? "(unknown)" : selected.slice(0, 12) }); - return out.sort((a, b) => a.label.localeCompare(b.label)); + for (const r of runs) { + const p = r.project; + if (!p || typeof p.id !== "string") continue; + const g = by.get(p.id) || { name: String(p.name || p.id), ws: new Map() }; + by.set(p.id, g); + if (typeof p.ws === "string") g.ws.set(p.ws, String(p.root || "")); + } + const names = [...by.values()].map((g) => g.name); + const groups = [...by.entries()].map(([id, g]) => { + const name = names.filter((n) => n === g.name).length > 1 ? `${g.name} [${id.slice(0, 6)}]` : g.name; + const ws = [...g.ws.entries()].sort((a, b) => a[1].localeCompare(b[1])); + const label = ws.length === 1 && ws[0][1] ? `${name} (${ws[0][1]})` : ws.length > 1 ? `${name} (${ws.length} workspaces)` : name; + return { name, head: { id: `p:${id}`, label, depth: 0 }, kids: ws.length > 1 ? ws.map(([w, root]) => ({ id: `w:${w}`, label: root || w, depth: 1 })) : [] }; + }).sort((a, b) => a.name.localeCompare(b.name)); + const out = groups.flatMap((g) => [g.head, ...g.kids]); + if (runs.some((r) => !r.project)) out.push({ id: "p:unknown", label: "(unknown)", depth: 0 }); + const sel = selectionOf(selected); + if (selected && !out.some((o) => o.id === sel)) out.push({ id: sel, label: sel === "p:unknown" ? "(unknown)" : sel.slice(2, 14), depth: 0 }); + return out; +} + +/** A stored selection in `p:`/`w:` form; a bare id (stored before workspaces) is a project. @param {string} v */ +export function selectionOf(v) { + return !v ? "" : /^[pw]:/.test(v) ? v : `p:${v}`; +} + +/** The API filter for a selection: `{project}` or `{ws}`. @param {string} v */ +export function projectFilter(v) { + const s = selectionOf(v); + return s.startsWith("w:") ? { project: "", ws: s.slice(2) } : { project: s.slice(2), ws: "" }; } -/** @param {{id: string, label: string}[]} options @param {string} value @param {((e: Event) => void)|null} onchange */ +/** @param {{id: string, label: string, depth: number}[]} options @param {string} value @param {((e: Event) => void)|null} onchange */ export function projectSelect(options, value, onchange) { - return h("select", { name: "project", onchange }, h("option", { value: "" }, "any"), options.map((o) => h("option", { value: o.id, selected: o.id === value }, o.label))); + const sel = selectionOf(value); + return h("select", { name: "project", onchange }, h("option", { value: "" }, "any"), + options.map((o) => h("option", { value: o.id, selected: o.id === sel }, `${o.depth ? "\u00a0\u00a0\u2514 " : ""}${o.label}`))); } function select(name, options, value, onchange) { @@ -61,7 +90,7 @@ export function create(ctx) { return update(); } const since = f.since ? new Date(`${f.since}T00:00:00`).getTime() : ""; - api.runs({ q: f.q, tool: f.tool, provider: f.provider, status: f.status, project: f.project, since }).then((body) => { + api.runs({ q: f.q, tool: f.tool, provider: f.provider, status: f.status, ...projectFilter(f.project), since }).then((body) => { if (mine !== seq) return; rows = Array.isArray(body && body.runs) ? body.runs : []; error = null; @@ -126,7 +155,8 @@ export function create(ctx) { return h("li", {}, h("a", { class: `index-row${s.runId === selected ? " is-selected" : ""}`, href: `#/runs/${encodeURIComponent(s.runId)}` }, h("span", { class: "c-status" }, statusMark(s.status)), h("span", { class: "c-time num" }, fmtTime(s.startedAt)), - h("span", { class: "c-proj", title: s.project ? s.project.root : "no project recorded" }, s.project ? s.project.name : "-"), + h("span", { class: "c-proj", title: s.project ? `${s.project.name} (${s.project.root})` : "no project recorded" }, s.project ? s.project.name : "-", + s.project && s.project.root ? h("span", { class: "c-ws" }, ` (${s.project.root})`) : null), h("span", { class: "c-tool" }, s.tool || "-", s.legacy ? h("span", { class: "flag" }, "summary only") : null), h("span", { class: "c-wf" }, s.workflow || "-"), h("span", { class: "c-id", title: s.runId }, midId(s.runId, 16)), diff --git a/test/core-project.test.js b/test/core-project.test.js index 7476c7c..0048c60 100644 --- a/test/core-project.test.js +++ b/test/core-project.test.js @@ -109,3 +109,65 @@ test("PRJ7: a malformed .git file is treated as a plain repo root, not a throw", assert.equal(p.name, "odd"); assert.equal(p.root, dir); }); + +/** Write `[remote]` sections into a git dir's config. @param {string} gitDir @param {Record} remotes */ +function remotes(gitDir, remotes) { + fs.writeFileSync(path.join(gitDir, "config"), "[core]\n\tbare = false\n" + Object.entries(remotes).map(([n, u]) => `[remote "${n}"]\n\turl = ${u}\n\tfetch = +refs/heads/*:refs/remotes/${n}/*\n`).join("")); +} + +test("PRJ8: parseRemote reads org/repo from scp, ssh and https forms and rejects local remotes", () => { + const { parseRemote } = require("../core/project.js"); + const cases = /** @type {[string, (null|{host: string, path: string})][]} */ ([ + ["git@github.com:acme/app.git", { host: "github.com", path: "acme/app" }], + ["ssh://git@github.com:22/acme/app", { host: "github.com", path: "acme/app" }], + ["https://user:secret@GitHub.com/acme/app.git/", { host: "github.com", path: "acme/app" }], + ["https://gitlab.com/group/sub/repo", { host: "gitlab.com", path: "group/sub/repo" }], + ["file:///srv/git/app.git", null], + ["/srv/git/app.git", null], + ["../app", null], + ["C:/repos/app", null], + ["git@host:onlyname", null], + ]); + for (const [url, want] of cases) assert.deepEqual(parseRemote(url), want, url); +}); + +test("PRJ9: clones and worktrees of one remote share the group id and org/repo name, with separate workspaces", () => { + const base = tmp(); + const main = mainRepo(base, "app"); + remotes(path.join(main, ".git"), { upstream: "git@github.com:someone/other.git", origin: "git@github.com:acme/app.git" }); + const wt = worktree(base, main, "t3-1234"); + const clone = mainRepo(base, "app-review"); + remotes(path.join(clone, ".git"), { origin: "https://github.com/acme/app" }); + const m = resolveProject(main), w = resolveProject(wt), c = resolveProject(clone); + assert.ok(m && w && c); + assert.equal(m.name, "acme/app", "origin wins over the first remote"); + assert.equal(w.id, m.id); + assert.equal(c.id, m.id, "a separate clone joins the group"); + assert.equal(c.name, "acme/app"); + assert.equal(c.root, clone); + assert.equal(new Set([m.ws, w.ws, c.ws]).size, 3, "each checkout is its own workspace"); + assert.equal(m.ws, projectIdOf(main)); + assert.ok(!JSON.stringify(c).includes("github.com"), "no URL or host is stored"); +}); + +test("PRJ10: a repo with no usable remote keeps the git-dir id it had before remotes were read", () => { + const root = mainRepo(tmp(), "local"); + remotes(path.join(root, ".git"), { origin: "/srv/git/local.git" }); + const p = resolveProject(root); + assert.ok(p); + assert.equal(p.id, projectIdOf(path.join(root, ".git"))); + assert.equal(p.name, "local"); +}); + +test("PRJ11: normalizeProject regroups a pre-ws run whose root still exists, keeps the old group otherwise", () => { + const { normalizeProject } = require("../core/project.js"); + const root = mainRepo(tmp(), "svc"); + remotes(path.join(root, ".git"), { origin: "git@github.com:acme/svc.git" }); + const now = resolveProject(root); + assert.deepEqual(normalizeProject({ id: "aaaaaaaaaaaa", name: "svc", root }), now); + const gone = normalizeProject({ id: "bbbbbbbbbbbb", name: "old", root: "/no/such/dir" }); + assert.deepEqual(gone, { id: "bbbbbbbbbbbb", name: "old", root: "/no/such/dir", ws: projectIdOf("/no/such/dir") }); + const current = { id: "cccccccccccc", name: "acme/svc", root: "/elsewhere", ws: "dddddddddddd" }; + assert.deepEqual(normalizeProject(current), current, "a run that has ws is taken as recorded"); + assert.equal(normalizeProject("/raw"), null); +}); diff --git a/test/dashboard-analyzer.test.js b/test/dashboard-analyzer.test.js index 2cc4ac2..6130999 100644 --- a/test/dashboard-analyzer.test.js +++ b/test/dashboard-analyzer.test.js @@ -220,6 +220,16 @@ test("AN13: per-project table groups by id, unknown when no project, with latenc assert.equal(unknown.runs, 1); }); +test("AN13b: a project row nests its workspaces with their own metrics", () => { + const a = { id: "aaaaaaaaaaaa", name: "acme/svc", root: "/x/svc", ws: "111111111111" }; + const b = { ...a, root: "/tmp/rev", ws: "222222222222" }; + const out = analyze([call({ project: a, ms: 1000 }), call({ project: a, ms: 2000 }), call({ project: b, ms: 3000, error: "timeout" })]); + const row = out.projects.find((/** @type {any} */ r) => r.id === a.id); + assert.equal(row.runs, 3); + assert.equal(row.timeouts, 1); + assert.deepEqual(row.workspaces.map((/** @type {any} */ w) => [w.ws, w.root, w.runs, w.timeouts]), [[a.ws, a.root, 2, 0], [b.ws, b.root, 1, 1]]); +}); + test("AN14: OpenRouter models use models. keys", () => { assert.equal(A.timeoutKey("openrouter:kimi"), "models.kimi.timeout"); assert.equal(A.dropKey("openrouter:kimi"), "models.kimi.consensus"); diff --git a/test/dashboard-runs.test.js b/test/dashboard-runs.test.js index abe8fa2..e4869a5 100644 --- a/test/dashboard-runs.test.js +++ b/test/dashboard-runs.test.js @@ -344,9 +344,22 @@ test("RR15: run summaries carry the run_start project; ?project= filters by id, writeRun(dir, "r-bad", [runStart({ project: "/raw" }), runEnd({})]); const index = createRunIndex({ runsDir: dir, isAlive: () => false }); const all = index.list(); - assert.deepEqual(all.find((r) => r.runId === "r-app")?.project, app); + assert.deepEqual(all.find((r) => r.runId === "r-app")?.project, { ...app, ws: require("../core/project.js").projectIdOf(app.root) }, "a missing root keeps its group and gets a ws"); assert.equal(all.find((r) => r.runId === "r-none")?.project, null); assert.equal(all.find((r) => r.runId === "r-bad")?.project, null); assert.deepEqual(index.list({ project: app.id }).map((r) => r.runId), ["r-app"]); assert.deepEqual(index.list({ project: "unknown" }).map((r) => r.runId).sort(), ["r-bad", "r-none"]); }); + +test("RR16: ?project= matches every workspace of the group, ?ws= one workspace and wins over project", () => { + const dir = tmpDir(); + const a = { id: "aaaaaaaaaaaa", name: "acme/svc", root: "/x/svc", ws: "111111111111" }; + const b = { ...a, root: "/tmp/svc-rev", ws: "222222222222" }; + writeRun(dir, "r-a", [runStart({ project: a }), runEnd({})]); + writeRun(dir, "r-b", [runStart({ project: b }), runEnd({})]); + writeRun(dir, "r-none", [runStart({}), runEnd({})]); + const index = createRunIndex({ runsDir: dir, isAlive: () => false }); + assert.deepEqual(index.list({ project: a.id }).map((r) => r.runId).sort(), ["r-a", "r-b"]); + assert.deepEqual(index.list({ ws: b.ws }).map((r) => r.runId), ["r-b"]); + assert.deepEqual(index.list({ project: "unknown", ws: a.ws }).map((r) => r.runId), ["r-a"]); +}); diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index 38f2a61..e26db36 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -518,6 +518,10 @@ test("S-AN1: /api/analyzer is guarded, masks the project root, and filters by pr const only = JSON.parse((await req(`/api/analyzer?project=${project.id}`, { cookie: true })).body); assert.equal(only.runs, 1); assert.equal((await req("/api/analyzer?project=..%2Fx", { cookie: true })).status, 400); + assert.equal((await req("/api/analyzer?ws=unknown", { cookie: true })).status, 400, "ws takes a 12-hex id only"); + const ws = JSON.parse((await req(`/api/analyzer?ws=${row.workspaces[0].ws}`, { cookie: true })).body); + assert.equal(ws.runs, 1); + assert.equal(ws.window.ws, row.workspaces[0].ws); assert.equal((await req("/api/analyzer?days=abc", { cookie: true })).status, 400); const runs = JSON.parse((await req(`/api/runs?project=${project.id}`, { cookie: true })).body).runs; assert.deepEqual(runs.map((/** @type {any} */ r) => r.runId), ["run-an"]); diff --git a/test/dashboard-ui.test.js b/test/dashboard-ui.test.js index 7762cfd..3689e96 100644 --- a/test/dashboard-ui.test.js +++ b/test/dashboard-ui.test.js @@ -505,3 +505,24 @@ test("UI-PRJ: the run_start project survives reduce, applySummary and summaryOf; const junk = reduce({}, ev(0, { kind: "run_start", tool: "ask-one", project: "/raw/path" })); assert.equal(summaryOf(junk["fan-1"]).project, null); }); + +test("UI-PRJ2: project options are a repo -> workspace tree; a stored bare id still selects its repo", async () => { + const { projectOptions, projectFilter, selectionOf } = await load("views/runs.js"); + const runs = [ + { project: { id: "aaaaaaaaaaaa", name: "acme/svc", root: "~/svc", ws: "111111111111" } }, + { project: { id: "aaaaaaaaaaaa", name: "acme/svc", root: "/tmp/rev", ws: "222222222222" } }, + { project: { id: "bbbbbbbbbbbb", name: "acme/api", root: "~/api", ws: "333333333333" } }, + { project: null }, + ]; + assert.deepEqual(projectOptions(runs, "").map((o) => [o.id, o.label, o.depth]), [ + ["p:bbbbbbbbbbbb", "acme/api (~/api)", 0], + ["p:aaaaaaaaaaaa", "acme/svc (2 workspaces)", 0], + ["w:222222222222", "/tmp/rev", 1], + ["w:111111111111", "~/svc", 1], + ["p:unknown", "(unknown)", 0], + ]); + assert.equal(selectionOf("aaaaaaaaaaaa"), "p:aaaaaaaaaaaa"); + assert.deepEqual(projectFilter("aaaaaaaaaaaa"), { project: "aaaaaaaaaaaa", ws: "" }); + assert.deepEqual(projectFilter("w:222222222222"), { project: "", ws: "222222222222" }); + assert.ok(projectOptions(runs, "w:999999999999").some((o) => o.id === "w:999999999999"), "the selection stays listed"); +});